feat: ClusterGame semi-works
This commit is contained in:
parent
199d7ed22c
commit
568e2c438a
15 changed files with 252 additions and 80 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
53
apps/frontend/src/ui/components/DynamicTime.tsx
Normal file
53
apps/frontend/src/ui/components/DynamicTime.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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' : ''}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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'}`} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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"))?)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue