diff --git a/apps/desktop/src/api/commands/cluster.rs b/apps/desktop/src/api/commands/cluster.rs index 23cf33e9..65d86853 100644 --- a/apps/desktop/src/api/commands/cluster.rs +++ b/apps/desktop/src/api/commands/cluster.rs @@ -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 diff --git a/apps/desktop/src/api/commands/mod.rs b/apps/desktop/src/api/commands/mod.rs index 8a49cff4..1b641a16 100644 --- a/apps/desktop/src/api/commands/mod.rs +++ b/apps/desktop/src/api/commands/mod.rs @@ -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, diff --git a/apps/desktop/src/api/commands/processor.rs b/apps/desktop/src/api/commands/processor.rs index 2c5df570..f7816308 100644 --- a/apps/desktop/src/api/commands/processor.rs +++ b/apps/desktop/src/api/commands/processor.rs @@ -8,12 +8,24 @@ pub async fn get_running_clusters() -> Result, String> { Ok(processor::get_running_clusters().await?) } +#[specta::specta] +#[tauri::command] +pub async fn is_cluster_running(uuid: Uuid) -> Result { + Ok(processor::is_cluster_running(uuid).await?) +} + #[specta::specta] #[tauri::command] pub async fn get_processes_by_path(path: ClusterPath) -> Result, String> { Ok(processor::get_uuids_by_cluster_path(path).await?) } +#[specta::specta] +#[tauri::command] +pub async fn get_pid_by_uuid(uuid: Uuid) -> Result { + Ok(processor::get_pid_by_uuid(uuid).await?) +} + #[specta::specta] #[tauri::command] pub async fn kill_process(uuid: Uuid) -> Result<(), String> { diff --git a/apps/frontend/src/ui/components/DynamicTime.tsx b/apps/frontend/src/ui/components/DynamicTime.tsx new file mode 100644 index 00000000..a90e3607 --- /dev/null +++ b/apps/frontend/src/ui/components/DynamicTime.tsx @@ -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 ; +} + +type InternalTimeProps = TimeProps & { + format: (time: number) => string; +}; + +function InternalTime(props: InternalTimeProps) { + const [time, setTime] = createSignal(''); + const [intervalId, setIntervalId] = createSignal(undefined); + + onMount(() => { + const loop = () => { + setTime(props.format(props.timestamp)); + }; + + loop(); + setIntervalId(setInterval(loop, 1000)); + }); + + onCleanup(() => { + clearInterval(intervalId()!); + }); + + return {time()}; +} diff --git a/apps/frontend/src/ui/components/Sidebar.tsx b/apps/frontend/src/ui/components/Sidebar.tsx index e61b3956..c9d6f5b6 100644 --- a/apps/frontend/src/ui/components/Sidebar.tsx +++ b/apps/frontend/src/ui/components/Sidebar.tsx @@ -4,7 +4,7 @@ import { For, createEffect, splitProps } from 'solid-js'; type SidebarProps = ParentProps & { base: string; - links: Record; + links: Record; }; 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 ( 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' : ''}` } diff --git a/apps/frontend/src/ui/components/TimeAgo.tsx b/apps/frontend/src/ui/components/TimeAgo.tsx deleted file mode 100644 index 41cdcf72..00000000 --- a/apps/frontend/src/ui/components/TimeAgo.tsx +++ /dev/null @@ -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(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 {time()}; -} - -export default TimeAgo; diff --git a/apps/frontend/src/ui/components/base/Toggle.tsx b/apps/frontend/src/ui/components/base/Toggle.tsx index 7d764e6f..09357bc5 100644 --- a/apps/frontend/src/ui/components/base/Toggle.tsx +++ b/apps/frontend/src/ui/components/base/Toggle.tsx @@ -18,7 +18,7 @@ function Toggle(props: ToggleProps) { return (
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} >
diff --git a/apps/frontend/src/ui/components/content/FormattedLog.tsx b/apps/frontend/src/ui/components/content/FormattedLog.tsx index ea251d23..bd84a7b3 100644 --- a/apps/frontend/src/ui/components/content/FormattedLog.tsx +++ b/apps/frontend/src/ui/components/content/FormattedLog.tsx @@ -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 ( - - - {line => } - - +
+ + + + {line => } + + + +
); } diff --git a/apps/frontend/src/ui/components/overlay/notifications/NotificationComponent.tsx b/apps/frontend/src/ui/components/overlay/notifications/NotificationComponent.tsx index 75ec0577..33ccabec 100644 --- a/apps/frontend/src/ui/components/overlay/notifications/NotificationComponent.tsx +++ b/apps/frontend/src/ui/components/overlay/notifications/NotificationComponent.tsx @@ -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 & { diff --git a/apps/frontend/src/ui/pages/cluster/ClusterGame.tsx b/apps/frontend/src/ui/pages/cluster/ClusterGame.tsx index fbfeac53..14fe16bb 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterGame.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterGame.tsx @@ -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(); + const navigate = useNavigate(); - // TODO: Save cluster settings + const [unlisten, setUnlisten] = createSignal(); + const [startedAt, setStartedAt] = createSignal(); + 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 (

Game Running

- -

TODO

-
+
+
+ + Started: + {' '} + + + + +
+ +

+ PID: + {' '} + {pid() || 0} +

+
+ +
); } +ClusterGame.buildUrl = function (uuid: string, process_uuid: string): URLSearchParams { + return new URLSearchParams({ id: uuid, process_uuid }); +}; + export default ClusterGame; diff --git a/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx b/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx index eafdedcb..eb9a9b9b 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterLogs.tsx @@ -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() {
No logs were found.}> -
- - - -
+
diff --git a/apps/frontend/src/ui/pages/cluster/ClusterRoot.tsx b/apps/frontend/src/ui/pages/cluster/ClusterRoot.tsx index 55d51858..386e56ad 100644 --- a/apps/frontend/src/ui/pages/cluster/ClusterRoot.tsx +++ b/apps/frontend/src/ui/pages/cluster/ClusterRoot.tsx @@ -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(); + 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 ( , 'Overview', '/'], - (supportsMods(cluster()) ? [, 'Mods', '/mods'] : undefined), + (supportsMods(cluster?.()) ? [, 'Mods', '/mods'] : undefined), [, 'Screenshots', '/screenshots'], [, 'Worlds', '/worlds'], [, 'Logs', '/logs'], [, '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 = ; // : ; + + return [icon, `Process #${index + 1}`, '/game', ClusterGame.buildUrl(cluster()!.uuid, process_uuid)]; + }), + } + : {}), }} /> ); diff --git a/apps/testing/src/main.rs b/apps/testing/src/main.rs index 7470f075..2f390385 100644 --- a/apps/testing/src/main.rs +++ b/apps/testing/src/main.rs @@ -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(); diff --git a/packages/core/src/api/cluster/mod.rs b/packages/core/src/api/cluster/mod.rs index b150a599..86cee05c 100644 --- a/packages/core/src/api/cluster/mod.rs +++ b/packages/core/src/api/cluster/mod.rs @@ -85,7 +85,7 @@ pub async fn list_grouped(clear: Option) -> crate::Result crate::Result>> { +pub async fn run_default(path: &ClusterPath) -> crate::Result>> { 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 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>> { + 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))] diff --git a/packages/core/src/api/processor.rs b/packages/core/src/api/processor.rs index c8b4069a..bdda3c1b 100644 --- a/packages/core/src/api/processor.rs +++ b/packages/core/src/api/processor.rs @@ -50,6 +50,13 @@ pub async fn get_running_clusters() -> crate::Result> { 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 { + 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> { @@ -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 { + 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"))?) +}