From 37296675e4471b42f72ebe754606896389e6d603 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Mon, 25 Aug 2025 21:16:32 +0200 Subject: [PATCH] feat: notifications and running game log --- .vscode/settings.json | 12 ++ apps/oneclient/frontend/package.json | 1 + apps/oneclient/frontend/src/bindings.gen.ts | 20 +- .../frontend/src/components/LaunchButton.tsx | 38 ++++ .../src/components/LogViewer.module.css | 4 +- .../frontend/src/components/LogViewer.tsx | 37 ++-- .../src/components/Spinner.module.css | 34 ++++ .../frontend/src/components/Spinner.tsx | 9 +- .../src/components/overlay/Toasts.module.css | 57 ++++++ .../src/components/overlay/Toasts.tsx | 189 ++++++++++++++++++ .../frontend/src/components/overlay/index.ts | 1 + .../frontend/src/hooks/useClusters.ts | 42 +++- .../frontend/src/hooks/useLaunchCluster.ts | 41 ++++ apps/oneclient/frontend/src/routes/__root.tsx | 9 +- .../frontend/src/routes/app/cluster/logs.tsx | 1 - .../src/routes/app/cluster/process.tsx | 76 +++++++ .../frontend/src/routes/app/cluster/route.tsx | 28 ++- .../frontend/src/routes/app/clusters.tsx | 28 +-- .../frontend/src/routes/app/index.tsx | 25 +-- .../frontend/src/styles/animations.css | 17 -- apps/oneclient/frontend/src/utils/toast.ts | 62 ++++++ packages/core/src/api/processes.rs | 81 +++++++- packages/core/src/api/tauri/commands.rs | 36 ++++ packages/core/src/store/processes.rs | 33 ++- packages/web_common/src/hooks/index.ts | 1 + .../web_common/src/hooks/useAsyncEffect.ts | 19 ++ packages/web_common/src/theme.css | 2 +- pnpm-lock.yaml | 18 ++ pnpm-workspace.yaml | 1 + 29 files changed, 818 insertions(+), 104 deletions(-) create mode 100644 apps/oneclient/frontend/src/components/LaunchButton.tsx create mode 100644 apps/oneclient/frontend/src/components/Spinner.module.css create mode 100644 apps/oneclient/frontend/src/components/overlay/Toasts.module.css create mode 100644 apps/oneclient/frontend/src/components/overlay/Toasts.tsx create mode 100644 apps/oneclient/frontend/src/hooks/useLaunchCluster.ts create mode 100644 apps/oneclient/frontend/src/routes/app/cluster/process.tsx create mode 100644 apps/oneclient/frontend/src/utils/toast.ts create mode 100644 packages/web_common/src/hooks/useAsyncEffect.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b599fcf..871b4462 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,18 @@ "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer" }, + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescript]": { + "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, "files.readonlyInclude": { "**/node_modules/**": true, "**/*.gen.ts": true diff --git a/apps/oneclient/frontend/package.json b/apps/oneclient/frontend/package.json index a8df14a4..93908972 100644 --- a/apps/oneclient/frontend/package.json +++ b/apps/oneclient/frontend/package.json @@ -38,6 +38,7 @@ "react-dom": "catalog:", "react-just-parallax": "catalog:", "react-markdown": "^10.1.0", + "react-toastify": "catalog:", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "skinview3d": "catalog:", diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 4692aaac..6ef73ffb 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -147,7 +147,9 @@ export type Process = { pid: number; started_at: string; cluster_id: number; pos export type ProcessError = { type: "HookUnsuccessful"; data: string } | { type: "NoPid"; data: string } -export type ProcessPayload = { type: "Starting"; command: string } | { type: "Started"; process: Process } | { type: "Stopped"; pid: number; exit_code: number } | { type: "Output"; pid: number; output: string } +export type ProcessPayload = { cluster_id: number; kind: ProcessPayloadKind } + +export type ProcessPayloadKind = { type: "Starting"; command: string } | { type: "Started"; pid: number; started_at: string; post_hook: string | null; account_id: string } | { type: "Stopped"; pid: number; exit_code: number } | { type: "Output"; pid: number; output: string } export type ProfileUpdate = { res: Resolution | null; force_fullscreen: boolean | null; mem_max: number | null; launch_args: string | null; launch_env: string | null; hook_pre: string | null; hook_wrapper: string | null; hook_post: string | null } @@ -167,7 +169,7 @@ export type SettingProfileModel = { name: string; java_id: number | null; res: R export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean } -export type SettingsOsExtra = Record +export type SettingsOsExtra = { enable_gamemode: boolean | null } export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated" @@ -230,9 +232,9 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'core':'{"getClusterById":["id"],"getLogByName":["id","name"],"getLogs":["id"],"openMsaLogin":[],"writeSettings":["setting"],"removeUser":["uuid"],"searchPackages":["provider","query"],"getClusters":[],"updateClusterById":["id","request"],"getUser":["uuid"],"getPackage":["provider","slug"],"getLoadersForVersion":["mc_version"],"getUsers":[],"getScreenshots":["id"],"getPackageBody":["provider","body"],"launchCluster":["id","uuid"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"updateClusterProfile":["name","profile"],"getMultiplePackages":["provider","slugs"],"setDefaultUser":["uuid"],"getWorlds":["id"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getUsersFromAuthor":["provider","author"],"fetchMinecraftProfile":["uuid"],"removeCluster":["id"],"readSettings":[],"getGlobalProfile":[],"getDefaultUser":["fallback"],"getGameVersions":[],"createCluster":["options"],"getProfileOrDefault":["name"]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}' } -export type Router = { 'oneclient': { openDevTools: () => Promise, -getClustersGroupedByMajor: () => Promise> }, +const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'core':'{"getClusterById":["id"],"getPackage":["provider","slug"],"getMultiplePackages":["provider","slugs"],"getLoadersForVersion":["mc_version"],"getClusters":[],"getLogs":["id"],"getProfileOrDefault":["name"],"openMsaLogin":[],"getUsersFromAuthor":["provider","author"],"launchCluster":["id","uuid"],"getLogByName":["id","name"],"fetchMinecraftProfile":["uuid"],"removeUser":["uuid"],"getUser":["uuid"],"killProcess":["pid"],"updateClusterById":["id","request"],"getGameVersions":[],"isClusterRunning":["cluster_id"],"removeCluster":["id"],"createCluster":["options"],"getRunningProcesses":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getPackageBody":["provider","body"],"readSettings":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"updateClusterProfile":["name","profile"],"getWorlds":["id"],"getUsers":[],"getRunningProcessesByClusterId":["cluster_id"],"getGlobalProfile":[],"getDefaultUser":["fallback"],"writeSettings":["setting"],"searchPackages":["provider","query"],"setDefaultUser":["uuid"],"getScreenshots":["id"]}', 'events':'{"message":["event"],"process":["event"],"ingress":["event"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"openDevTools":[]}' } +export type Router = { 'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, @@ -243,6 +245,10 @@ getScreenshots: (id: number) => Promise, getWorlds: (id: number) => Promise, getLogs: (id: number) => Promise, getLogByName: (id: number, name: string) => Promise, +getRunningProcesses: () => Promise, +getRunningProcessesByClusterId: (clusterId: number) => Promise, +isClusterRunning: (clusterId: number) => Promise, +killProcess: (pid: number) => Promise, getProfileOrDefault: (name: string | null) => Promise, getGlobalProfile: () => Promise, updateClusterProfile: (name: string, profile: ProfileUpdate) => Promise, @@ -267,8 +273,8 @@ fetchMinecraftProfile: (uuid: string) => Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, process: (event: ProcessPayload) => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise } }; +'oneclient': { openDevTools: () => Promise, +getClustersGroupedByMajor: () => Promise> } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/LaunchButton.tsx b/apps/oneclient/frontend/src/components/LaunchButton.tsx new file mode 100644 index 00000000..571929d6 --- /dev/null +++ b/apps/oneclient/frontend/src/components/LaunchButton.tsx @@ -0,0 +1,38 @@ +import type { ButtonProps } from '@onelauncher/common/components'; +import { useIsRunning } from '@/hooks/useClusters'; +import { useLaunchCluster } from '@/hooks/useLaunchCluster'; +import { Button } from '@onelauncher/common/components'; +import { tv } from 'tailwind-variants'; + +export type LaunchButtonProps = Omit & { + clusterId: number | undefined | null; +}; + +const launchButtonVariants = tv({ + variants: { + isRunning: { + true: 'bg-success hover:bg-success-hover pressed:bg-success-pressed disabled:bg-success-disabled pending:bg-success-disabled', + }, + }, +}); + +export function LaunchButton({ + clusterId, + isDisabled, + className, + ...rest +}: LaunchButtonProps) { + const launch = useLaunchCluster(clusterId); + const isRunning = useIsRunning(clusterId); + + return ( + + ); +} diff --git a/apps/oneclient/frontend/src/components/LogViewer.module.css b/apps/oneclient/frontend/src/components/LogViewer.module.css index 53312b83..8d472fe0 100644 --- a/apps/oneclient/frontend/src/components/LogViewer.module.css +++ b/apps/oneclient/frontend/src/components/LogViewer.module.css @@ -1,11 +1,11 @@ @reference "../styles/global.css"; .log_container { - @apply w-full whitespace-pre text-base font-mono bg-component-bg rounded-xl; + @apply relative w-full whitespace-pre text-base font-mono bg-component-bg rounded-xl; } .log { - @apply relative w-full whitespace-pre text-base font-mono; + @apply absolute top-0 left-0 w-full whitespace-pre text-base font-mono; } .log * { diff --git a/apps/oneclient/frontend/src/components/LogViewer.tsx b/apps/oneclient/frontend/src/components/LogViewer.tsx index a28f88c2..d3299761 100644 --- a/apps/oneclient/frontend/src/components/LogViewer.tsx +++ b/apps/oneclient/frontend/src/components/LogViewer.tsx @@ -1,13 +1,13 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; -import { useImperativeHandle, useMemo, useState } from 'react'; +import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import styles from './LogViewer.module.css'; export interface LogViewerProps { content: string; scrollRef: React.RefObject; autoScroll?: boolean; - ref?: React.RefObject; + ref?: React.Ref; } export interface LogViewerRef { @@ -25,6 +25,7 @@ export function LogViewer({ ref, }: LogViewerProps) { const lines = useMemo>(() => content.split('\n'), [content]); + const containerRef = useRef(null); // used to force re-render (and re-init of virtualizer hook) const [_lastUpdated, setLastUpdated] = useState(Date.now()); @@ -36,21 +37,30 @@ export function LogViewer({ paddingStart: 4, paddingEnd: 4, gap: 0, - overscan: 20, + overscan: 8, + scrollMargin: containerRef.current ? containerRef.current.offsetTop + 75 : 0, }); - useImperativeHandle(ref, () => ({ - push: (line) => { - const len = lines.push(line); - setLastUpdated(Date.now()); + const scrollToEnd = useCallback(() => { + requestAnimationFrame(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })); + }, [scrollRef]); - if (autoScroll) - virtualizer.scrollToIndex(len - 1); - }, - }), [autoScroll, lines, virtualizer]); + const appendLine = useCallback((line: string) => { + setLastUpdated(Date.now()); + lines.push(line); + + if (autoScroll) + scrollToEnd(); + }, [autoScroll, lines, scrollToEnd]); + + useImperativeHandle(ref, () => ({ + push: appendLine, + scrollToEnd, + }), [appendLine, scrollToEnd]); return (
- {virtualizer.getVirtualItems().map(virtualRow => ( ))} @@ -109,7 +118,7 @@ function LogLine({ [{time}] [{threadName}/{level}] - {isChat ? ' [CHAT] ' : ' '}{line.slice(matched.length)} + {isChat ? ' [CHAT]' : ' '}{line.slice(matched.length)}
); diff --git a/apps/oneclient/frontend/src/components/Spinner.module.css b/apps/oneclient/frontend/src/components/Spinner.module.css new file mode 100644 index 00000000..64878852 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Spinner.module.css @@ -0,0 +1,34 @@ +@keyframes animloader { + 0% { + transform: scale(0); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 0; + } +} + +.loader { + display: inline-block; + position: relative; +} + +.loader::after, +.loader::before { + content: ''; + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 50%; + border: 2px solid #FFF; + position: absolute; + left: 0; + top: 0; + opacity: 0; + animation: animloader 2s linear infinite; +} + +.loader::after { + animation-delay: 1s; +} diff --git a/apps/oneclient/frontend/src/components/Spinner.tsx b/apps/oneclient/frontend/src/components/Spinner.tsx index af8794e7..c451aa65 100644 --- a/apps/oneclient/frontend/src/components/Spinner.tsx +++ b/apps/oneclient/frontend/src/components/Spinner.tsx @@ -1,11 +1,13 @@ import type { HTMLAttributes } from 'react'; import type { VariantProps } from 'tailwind-variants'; import { tv } from 'tailwind-variants'; +import styles from './Spinner.module.css'; const spinnerVariants = tv({ - base: 'inline-block relative opacity-0 animate-delay-1000 animate-fade animate-fill-forwards', + base: styles.loader, variants: { size: { + extraSmall: 'w-6 h-6', small: 'w-8 h-8', medium: 'w-12 h-12', large: 'w-16 h-16', @@ -24,9 +26,6 @@ export function Spinner({ className, }: SpinnerProps) { return ( -
-
-
-
+
); } diff --git a/apps/oneclient/frontend/src/components/overlay/Toasts.module.css b/apps/oneclient/frontend/src/components/overlay/Toasts.module.css new file mode 100644 index 00000000..d34f61a4 --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/Toasts.module.css @@ -0,0 +1,57 @@ +:root { + --toastify-color-light: transparent; + --toastify-color-dark: transparent; + --toastify-color-info: var(--toastify-color-dark); + --toastify-color-success: var(--color-success); + --toastify-color-warning: var(--color-warning); + --toastify-color-error: var(--color-error); + --toastify-color-transparent: rgba(255, 255, 255, 0.7); + + --toastify-icon-color-info: var(--color-code-info); + --toastify-icon-color-success: var(--color-code-success); + --toastify-icon-color-warning: var(--color-code-warning); + --toastify-icon-color-error: var(--color-code-error); + + --toastify-container-width: fit-content; + --toastify-toast-width: 320px; + --toastify-toast-offset: 16px; + --toastify-toast-top: max(var(--toastify-toast-offset), env(safe-area-inset-top)); + --toastify-toast-right: max(var(--toastify-toast-offset), env(safe-area-inset-right)); + --toastify-toast-left: max(var(--toastify-toast-offset), env(safe-area-inset-left)); + --toastify-toast-bottom: max(var(--toastify-toast-offset), env(safe-area-inset-bottom)); + --toastify-toast-padding: 0px; + --toastify-toast-min-height: 0px; + --toastify-toast-max-height: 800px; + --toastify-toast-bd-radius: 0px; + --toastify-toast-shadow: var(--shadow-lg); + --toastify-font-family: var(--font-sans); + --toastify-z-index: 9999; + --toastify-text-color-light: var(--color-fg-primary); + --toastify-text-color-dark: var(--color-fg-primary); + + /* Used only for colored theme */ + --toastify-text-color-info: #fff; + --toastify-text-color-success: #fff; + --toastify-text-color-warning: #fff; + --toastify-text-color-error: #fff; + + --toastify-spinner-color: #616161; + --toastify-spinner-color-empty-area: #e0e0e0; + --toastify-color-progress-light: linear-gradient(to right, #4cd964, #5ac8fa, #007aff, #34aadc, #5856d6, #ff2d55); + --toastify-color-progress-dark: #bb86fc; + --toastify-color-progress-info: var(--toastify-color-info); + --toastify-color-progress-success: var(--toastify-color-success); + --toastify-color-progress-warning: var(--toastify-color-warning); + --toastify-color-progress-error: var(--toastify-color-error); + /* used to control the opacity of the progress trail */ + --toastify-color-progress-bgo: 0.2; +} + +@keyframes custom-anim-progress-bar { + 0% { + width: 0%; + } + 100% { + width: 100%; + } +} diff --git a/apps/oneclient/frontend/src/components/overlay/Toasts.tsx b/apps/oneclient/frontend/src/components/overlay/Toasts.tsx new file mode 100644 index 00000000..a4b53102 --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/Toasts.tsx @@ -0,0 +1,189 @@ +import type { ToastData, ToastId } from '@/utils/toast'; +import type { HTMLAttributes } from 'react'; +import type { ToastContentProps, ToastOptions } from 'react-toastify'; +import { bindings } from '@/main'; +import { toast, toastUpdate } from '@/utils/toast'; +import { Button } from '@onelauncher/common/components'; +import { AlertCircleIcon, AlertTriangleIcon, CheckCircleIcon, InfoCircleIcon, XCloseIcon } from '@untitled-theme/icons-react'; +import { useEffect } from 'react'; +import { cssTransition, ToastContainer } from 'react-toastify'; +import { tv } from 'tailwind-variants'; +import { Spinner } from '../Spinner'; +import styles from './Toasts.module.css'; + +const toastVariants = tv({ + slots: { + container: [ + 'relative h-full flex flex-row w-full justify-start items-center gap-3 py-4 px-4 rounded-lg overflow-hidden', + 'inset-ring inset-ring-component-border bg-component-bg', + ], + closeBtn: [ + 'absolute top-2 right-2', + ], + }, + variants: { + clickable: { + true: { + container: [ + 'hover:bg-component-bg-hover active:bg-component-bg-pressed', + ], + }, + false: { + container: [], + }, + }, + }, +}); + +export function Toast({ + closeToast, + data, + toastProps, + isPaused, +}: ToastContentProps) { + const { container, closeBtn } = toastVariants({ + clickable: toastProps.closeOnClick || typeof toastProps.onClick !== 'undefined', + }); + + return ( +
+ + +
+

{data.title}

+ {data.message &&

{data.message}

} +
+ +
+ +
+ +
+ +
+
+ ); +} + +function ToastProgressBar({ + isPaused, + toastProps, + closeToast, +}: { + toastProps: ToastOptions; + isPaused: boolean; + closeToast: () => void; +}) { + const attrs: HTMLAttributes = {}; + + if (typeof toastProps.progress === 'number') { + attrs.style = { + transition: 'width 0.2s ease-in-out', + width: `${toastProps.progress * 100}%`, + }; + + if (toastProps.progress >= 1) + attrs.onTransitionEnd = closeToast; + } + else { + attrs.style = { + animationName: styles['custom-anim-progress-bar'], + animationDuration: `${toastProps.autoClose}ms`, + animationPlayState: isPaused ? 'paused' : 'running', + animationTimingFunction: 'linear', + animationDelay: '500ms', + }; + + attrs.onAnimationEnd = closeToast; + } + + return ( +
+
+ ); +} + +export function Toasts() { + // runs twice in dev mode because of react strict mode + // the double notifications are NOT a bug!!! + useEffect(() => { + let unlisten: (() => void) | undefined; + const toastIngressMap: Map = new Map(); + + bindings.events.ingress.on((e) => { + const title = typeof e.ingress_type === 'string' ? e.ingress_type : Object.keys(e.ingress_type).at(0) ?? 'Info'; + + const existingToastId = toastIngressMap.get(e.id); + + if (existingToastId) { + toastUpdate(existingToastId, { + progress: e.percent ?? 1, + isLoading: true, + message: e.message, + title, + }); + } + else { + const toastId = toast({ + type: 'info', + title, + progress: e.percent ?? 1, + isLoading: true, + message: e.message, + }); + + toastIngressMap.set(e.id, toastId); + } + }).then((fn) => { + unlisten = fn; + }); + + return () => { + if (unlisten) + unlisten(); + }; + }, []); + + return ( + + ); +} + +function ToastIcon({ + isLoading, + type, +}: { + isLoading: boolean | undefined; + type: string; +}) { + if (isLoading) + return ; + + switch (type) { + case 'success': + return ; + case 'warning': + return ; + case 'error': + return ; + case 'info': + default: + return ; + } +} diff --git a/apps/oneclient/frontend/src/components/overlay/index.ts b/apps/oneclient/frontend/src/components/overlay/index.ts index 275ebbdc..c0ccba68 100644 --- a/apps/oneclient/frontend/src/components/overlay/index.ts +++ b/apps/oneclient/frontend/src/components/overlay/index.ts @@ -2,3 +2,4 @@ export * from './AccountPopup'; export * from './AddAccountModal'; export * from './Overlay'; export * from './Popup'; +export * from './Toasts'; diff --git a/apps/oneclient/frontend/src/hooks/useClusters.ts b/apps/oneclient/frontend/src/hooks/useClusters.ts index 620304ff..6a718850 100644 --- a/apps/oneclient/frontend/src/hooks/useClusters.ts +++ b/apps/oneclient/frontend/src/hooks/useClusters.ts @@ -1,6 +1,8 @@ import { bindings } from '@/main'; import useAppShellStore from '@/stores/appShellStore'; -import { useCommandSuspense } from '@onelauncher/common'; +import { useAsyncEffect, useCommandSuspense } from '@onelauncher/common'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; export function useLastPlayedClusters() { return useCommandSuspense( @@ -31,3 +33,41 @@ export function useActiveCluster() { return clusters.find(c => c.id === clusterId) ?? clusters.at(0); } + +export function useIsRunning(clusterId: number | undefined | null) { + const [running, setRunning] = useState(false); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!clusterId) + return; + + const query = queryClient.ensureQueryData({ + queryKey: ['isClusterRunning', clusterId], + queryFn: () => bindings.core.isClusterRunning(clusterId), + staleTime: 0, + gcTime: 0, + }); + + query.then(setRunning); + + return () => { + setRunning(false); + }; + }, [clusterId, queryClient]); + + useAsyncEffect(async () => { + const unlisten = await bindings.events.process.on((e) => { + if (e.cluster_id !== clusterId) + return; + + setRunning(e.kind.type !== 'Stopped'); + }); + + return () => { + unlisten(); + }; + }, []); + + return running; +} diff --git a/apps/oneclient/frontend/src/hooks/useLaunchCluster.ts b/apps/oneclient/frontend/src/hooks/useLaunchCluster.ts new file mode 100644 index 00000000..6b227e20 --- /dev/null +++ b/apps/oneclient/frontend/src/hooks/useLaunchCluster.ts @@ -0,0 +1,41 @@ +import { bindings } from '@/main'; +import { toast } from '@/utils/toast'; +import { getMessageFromError, isLauncherError } from '@onelauncher/common'; +import { useNavigate } from '@tanstack/react-router'; +import { useCallback } from 'react'; + +export function useLaunchCluster(): (clusterId: number | undefined | null) => void; +export function useLaunchCluster(clusterId: number | undefined | null): () => void; + +export function useLaunchCluster(clusterId?: number | undefined | null) { + const navigate = useNavigate(); + + const launch = useCallback( + async (id?: number | null) => { + const targetId = clusterId ?? id; // prefer the one from hook arg + + if (!targetId) + return; + + try { + await bindings.core.launchCluster(targetId, null); + + navigate({ + to: '/app/cluster/process', + search: { clusterId: targetId }, + }); + } + catch (err) { + if (isLauncherError(err)) + toast({ + type: 'error', + title: 'Launch Error', + message: getMessageFromError(err), + }); + } + }, + [clusterId, navigate], + ); + + return launch; +} diff --git a/apps/oneclient/frontend/src/routes/__root.tsx b/apps/oneclient/frontend/src/routes/__root.tsx index 54861be1..47d9cf30 100644 --- a/apps/oneclient/frontend/src/routes/__root.tsx +++ b/apps/oneclient/frontend/src/routes/__root.tsx @@ -1,5 +1,6 @@ import type { QueryClient } from '@tanstack/react-query'; import type { NavigateOptions, ToOptions } from '@tanstack/react-router'; +import { Toasts } from '@/components/overlay'; import { TanStackDevtools } from '@tanstack/react-devtools'; import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router'; @@ -24,23 +25,27 @@ export const Route = createRootRouteWithContext()({ function RootRoute() { const router = useRouter(); return ( - // router.navigate({ to, ...options })} useHref={to => router.buildLocation({ to }).href} > +
+ +
- //
); } function DevTools() { return ( bindings.core.getLogByName(cluster.id, fileName), diff --git a/apps/oneclient/frontend/src/routes/app/cluster/process.tsx b/apps/oneclient/frontend/src/routes/app/cluster/process.tsx new file mode 100644 index 00000000..02a3563e --- /dev/null +++ b/apps/oneclient/frontend/src/routes/app/cluster/process.tsx @@ -0,0 +1,76 @@ +import type { LogViewerRef } from '@/components/LogViewer'; +import { LoaderContainer, SheetPage, useSheetPageContext } from '@/components'; +import { LogViewer } from '@/components/LogViewer'; +import { bindings } from '@/main'; +import { getMessageFromError, useAsyncEffect, useCommand } from '@onelauncher/common'; +import { createFileRoute } from '@tanstack/react-router'; +import { useRef } from 'react'; + +export const Route = createFileRoute('/app/cluster/process')({ + component: RouteComponent, +}); + +function RouteComponent() { + const { scrollContainerRef } = useSheetPageContext(); + + return ( + +
+
+

Game Log

+
+ + +
+
+ ); +} + +function LogContent({ + scrollRef, +}: { + scrollRef: React.RefObject; +}) { + const { cluster } = Route.useRouteContext(); + const logViewerRef = useRef(null); + + const { data: content, error, isLoading, refetch } = useCommand( + ['getLogByName', cluster.id, 'latest.log'], + () => bindings.core.getLogByName(cluster.id, 'latest.log'), + { + staleTime: 0, + }, + ); + + useAsyncEffect(async () => { + const unlisten = await bindings.events.process.on((e) => { + if (e.cluster_id !== cluster.id) + return; + + if (e.kind.type === 'Started') { + refetch(); + return; + } + + if (e.kind.type !== 'Output') + return; + + logViewerRef.current?.push(e.kind.output); + }); + + return () => { + unlisten(); + }; + }, []); + + return ( + + + + ); +} diff --git a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx index 832f6573..a890bd11 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx @@ -1,5 +1,7 @@ import { LoaderSuspense, Tab, TabList } from '@/components'; +import { LaunchButton } from '@/components/LaunchButton'; import { SheetPage } from '@/components/SheetPage'; +import { useIsRunning } from '@/hooks/useClusters'; import { bindings } from '@/main'; import { prettifyLoader } from '@/utils/loaders'; import { getVersionInfoOrDefault } from '@/utils/versionMap'; @@ -40,9 +42,12 @@ export const Route = createFileRoute('/app/cluster')({ }); function RouteComponent() { + const { cluster } = Route.useRouteContext(); const tabListRef = useRef(null); const scrollContainerRef = useRef(null); + const isRunning = useIsRunning(cluster.id); + const { scrollYProgress } = useScroll({ axis: 'y', container: scrollContainerRef, @@ -68,6 +73,8 @@ function RouteComponent() { Overview Logs Browser + {isRunning && } + {isRunning && Process}
@@ -85,8 +92,6 @@ function HeaderLarge() { const openFolder = () => bindings.folders.openCluster(cluster.folder_name); - const launch = () => bindings.core.launchCluster(cluster.id, null); - return (
@@ -110,13 +115,10 @@ function HeaderLarge() { - + />
); @@ -127,8 +129,6 @@ function HeaderSmall() { const openFolder = () => bindings.folders.openCluster(cluster.folder_name); - const launch = () => bindings.core.launchCluster(cluster.id, null); - return (

{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}

@@ -142,12 +142,10 @@ function HeaderSmall() { - + />
); diff --git a/apps/oneclient/frontend/src/routes/app/clusters.tsx b/apps/oneclient/frontend/src/routes/app/clusters.tsx index 005b9044..eb415ce1 100644 --- a/apps/oneclient/frontend/src/routes/app/clusters.tsx +++ b/apps/oneclient/frontend/src/routes/app/clusters.tsx @@ -1,5 +1,6 @@ import type { GameLoader } from '@/bindings.gen'; import { GameBackground } from '@/components'; +import { LaunchButton } from '@/components/LaunchButton'; import { SheetPage } from '@/components/SheetPage'; import { bindings } from '@/main'; import useClusterStore from '@/stores/clusterStore'; @@ -92,6 +93,18 @@ function RouteComponent() { const versionInfo = useMemo(() => getVersionInfoOrDefault(cluster?.mc_version), [cluster]); + const view = useCallback(() => { + if (!cluster) + return; + + navigate({ + to: `/app/cluster/overview`, + search: { + clusterId: cluster.id, + }, + }); + }, [cluster, navigate]); + return ( } @@ -183,21 +196,12 @@ function RouteComponent() { )}
- + + + + diff --git a/apps/oneclient/frontend/src/styles/animations.css b/apps/oneclient/frontend/src/styles/animations.css index 95bb2b4a..4267444c 100644 --- a/apps/oneclient/frontend/src/styles/animations.css +++ b/apps/oneclient/frontend/src/styles/animations.css @@ -1,15 +1,4 @@ @layer utilities { - @keyframes custom-anim-fade-scale { - 0% { - transform: scale(0); - opacity: 1; - } - 100% { - transform: scale(1); - opacity: 0; - } - } - @keyframes custom-anim-zoom { 0% { transform: scale(0); @@ -20,12 +9,6 @@ } } -@utility animate-fade-scale { - transform: scale(0); - opacity: 1; - animation: custom-anim-fade-scale var(--default-animation-duration, 1s) var(--default-animation-timing-function, cubic-bezier(0, 0, 0.2, 1)) var(--default-animation-delay, 0s) forwards; -} - @utility animate-zoom { transform: scale(0); animation: custom-anim-zoom var(--default-animation-duration, 1s) var(--default-animation-timing-function, cubic-bezier(0, 0, 0.2, 1)) var(--default-animation-delay, 0s) forwards; diff --git a/apps/oneclient/frontend/src/utils/toast.ts b/apps/oneclient/frontend/src/utils/toast.ts new file mode 100644 index 00000000..f6fc8069 --- /dev/null +++ b/apps/oneclient/frontend/src/utils/toast.ts @@ -0,0 +1,62 @@ +import type { UpdateOptions } from 'react-toastify'; +import { Toast } from '@/components/overlay'; +import { toast as toastify } from 'react-toastify'; + +export type ToastId = string | number; + +export interface ToastData { + type: 'info' | 'success' | 'warning' | 'error'; + title: string; + message?: string | undefined; +} + +export interface ToastOptions extends ToastData { + progress?: number; + isLoading?: boolean | undefined; + autoClose?: number | false | undefined; +} + +export function toast({ + progress, + isLoading, + autoClose = 5000, + ...data +}: ToastOptions): ToastId { + return toastify(Toast, { + data, + progress, + isLoading, + autoClose, + closeOnClick: false, + closeButton: false, + hideProgressBar: true, + icon: false, + }); +} + +export function toastUpdate( + toastId: ToastId, + { + progress, + isLoading, + autoClose, + ...data + }: Partial, +) { + const objData: UpdateOptions> = { + progress, + isLoading, + }; + + for (const [key, value] of Object.entries(data)) { + if (value === undefined || value === null) + continue; + + if (!objData.data) + objData.data = {}; + + objData.data[key] = value; + } + + toastify.update(toastId, objData); +} diff --git a/packages/core/src/api/processes.rs b/packages/core/src/api/processes.rs index aa055aba..739ce962 100644 --- a/packages/core/src/api/processes.rs +++ b/packages/core/src/api/processes.rs @@ -1,13 +1,82 @@ +use chrono::{DateTime, Utc}; use serde::Serialize; +use uuid::Uuid; +use crate::api::cluster::dao::ClusterId; +use crate::error::LauncherResult; +use crate::store::State; use crate::store::processes::Process; #[onelauncher_macro::specta] #[derive(Debug, Clone, Serialize)] -#[serde(tag = "type")] -pub enum ProcessPayload { - Starting { command: String }, - Started { process: Process }, - Stopped { pid: u32, exit_code: i32 }, - Output { pid: u32, output: String }, +pub struct ProcessPayload { + pub cluster_id: ClusterId, + pub kind: ProcessPayloadKind, +} + +#[onelauncher_macro::specta] +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub enum ProcessPayloadKind { + Starting { + command: String, + }, + Started { + pid: u32, + started_at: DateTime, + post_hook: Option, + account_id: Uuid, + }, + Stopped { + pid: u32, + exit_code: i32, + }, + Output { + pid: u32, + output: String, + }, +} + +pub async fn get_running_processes() -> LauncherResult> { + let state = State::get().await?; + let running = state.processes.get_running().await; + + let processes = futures::future::join_all( + running + .into_iter() + .map(|process| async move { process.read().await.clone() }), + ) + .await; + + Ok(processes) +} + +pub async fn get_running_processes_by_cluster_id( + cluster_id: ClusterId, +) -> LauncherResult> { + let state = State::get().await?; + let running = state.processes.get_running_by_cluster(cluster_id).await; + + let processes = futures::future::join_all( + running + .into_iter() + .map(|process| async move { process.read().await.clone() }), + ) + .await; + + Ok(processes) +} + +pub async fn is_cluster_running(cluster_id: ClusterId) -> LauncherResult { + let state = State::get().await?; + Ok(state.processes.has_running(cluster_id).await) +} + +pub async fn kill_process(pid: u32) -> LauncherResult<()> { + let state = State::get().await?; + if let Some(process) = state.processes.get(pid).await { + process.read().await.child_type.write().await.kill().await?; + } + + Ok(()) } diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 29a9b414..825a0d18 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -3,6 +3,7 @@ use crate::api::packages::data::{ SearchResult, }; use crate::api::packages::provider::ProviderExt; +use crate::store::processes::Process; use crate::store::{Settings, State}; use crate::utils::pagination::Paginated; use interpulse::api::minecraft::Version; @@ -55,6 +56,21 @@ pub trait TauriLauncherApi { #[taurpc(alias = "getLogByName")] async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult>; + // MARK: API: processes + #[taurpc(alias = "getRunningProcesses")] + async fn get_running_processes() -> LauncherResult>; + + #[taurpc(alias = "getRunningProcessesByClusterId")] + async fn get_running_processes_by_cluster_id( + cluster_id: ClusterId, + ) -> LauncherResult>; + + #[taurpc(alias = "isClusterRunning")] + async fn is_cluster_running(cluster_id: ClusterId) -> LauncherResult; + + #[taurpc(alias = "killProcess")] + async fn kill_process(pid: u32) -> LauncherResult<()>; + // MARK: API: profiles #[taurpc(alias = "getProfileOrDefault")] async fn get_profile_or_default(name: Option) -> LauncherResult; @@ -322,6 +338,26 @@ impl TauriLauncherApi for TauriLauncherApiImpl { api::cluster::content::get_log_by_name(&cluster, &name).await } + // MARK: Impl: processes + async fn get_running_processes(self) -> LauncherResult> { + api::processes::get_running_processes().await + } + + async fn get_running_processes_by_cluster_id( + self, + cluster_id: ClusterId, + ) -> LauncherResult> { + api::processes::get_running_processes_by_cluster_id(cluster_id).await + } + + async fn is_cluster_running(self, cluster_id: ClusterId) -> LauncherResult { + api::processes::is_cluster_running(cluster_id).await + } + + async fn kill_process(self, pid: u32) -> LauncherResult<()> { + api::processes::kill_process(pid).await + } + // MARK: Impl: profiles async fn get_global_profile(self) -> SettingsProfile { api::setting_profiles::get_global_profile().await diff --git a/packages/core/src/store/processes.rs b/packages/core/src/store/processes.rs index 8d8c7f7b..75003c53 100644 --- a/packages/core/src/store/processes.rs +++ b/packages/core/src/store/processes.rs @@ -15,7 +15,7 @@ use uuid::Uuid; use crate::api::cluster; use crate::api::cluster::dao::ClusterId; use crate::api::game::log::{CensorMap, censor_line}; -use crate::api::processes::ProcessPayload; +use crate::api::processes::{ProcessPayload, ProcessPayloadKind}; use crate::api::proxy::event::{LauncherEvent, send_event}; use crate::error::LauncherResult; use crate::send_error; @@ -176,8 +176,11 @@ impl ProcessStore { settings: &setting_profiles::Model, mut command: Command, ) -> LauncherResult>> { - send_event(LauncherEvent::Process(ProcessPayload::Starting { - command: format!("{command:?}"), + send_event(LauncherEvent::Process(ProcessPayload { + cluster_id, + kind: ProcessPayloadKind::Starting { + command: format!("{command:?}"), + }, })) .await; @@ -224,9 +227,9 @@ impl ProcessStore { } { censor_line(&censors, &mut line); - send_event(LauncherEvent::Process(ProcessPayload::Output { - pid, - output: line, + send_event(LauncherEvent::Process(ProcessPayload { + cluster_id, + kind: ProcessPayloadKind::Output { pid, output: line }, })) .await; } @@ -251,6 +254,18 @@ impl ProcessStore { let process = Arc::new(RwLock::new(process)); let mut write_guard = self.processes.write().await; write_guard.insert(pid, process.clone()); + + send_event(LauncherEvent::Process(ProcessPayload { + cluster_id, + kind: ProcessPayloadKind::Started { + pid, + started_at, + post_hook: settings.hook_post.clone(), + account_id: creds.id, + }, + })) + .await; + tracing::info!("spawned process with pid {}", pid); Ok(process) @@ -285,9 +300,9 @@ impl ProcessStore { send_error!("{err:?}"); } - send_event(LauncherEvent::Process(ProcessPayload::Stopped { - pid, - exit_code, + send_event(LauncherEvent::Process(ProcessPayload { + cluster_id, + kind: ProcessPayloadKind::Stopped { pid, exit_code }, })) .await; diff --git a/packages/web_common/src/hooks/index.ts b/packages/web_common/src/hooks/index.ts index 1ace166c..cf8fca81 100644 --- a/packages/web_common/src/hooks/index.ts +++ b/packages/web_common/src/hooks/index.ts @@ -1,2 +1,3 @@ +export * from './useAsyncEffect'; export * from './useCommand'; export * from './usePagination'; diff --git a/packages/web_common/src/hooks/useAsyncEffect.ts b/packages/web_common/src/hooks/useAsyncEffect.ts new file mode 100644 index 00000000..ffa00674 --- /dev/null +++ b/packages/web_common/src/hooks/useAsyncEffect.ts @@ -0,0 +1,19 @@ +import type React from 'react'; +import { useEffect } from 'react'; + +type UnlistenFn = (() => any) | undefined; + +export function useAsyncEffect(cb: () => Promise, deps?: React.DependencyList) { + useEffect(() => { + let unlisten: UnlistenFn; + + cb().then((fn) => { + unlisten = fn; + }); + + return () => { + unlisten?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- the function should not depend on the cb but the deps list + }, deps); +} diff --git a/packages/web_common/src/theme.css b/packages/web_common/src/theme.css index f0be6864..2d8d0f9f 100644 --- a/packages/web_common/src/theme.css +++ b/packages/web_common/src/theme.css @@ -23,7 +23,7 @@ --color-brand: #2b4bff; --color-brand-hover: #2843dd; --color-brand-pressed: #3957ff; - --color-brand-disabled: #2543eb; + --color-brand-disabled: #1f2f81; --color-component-bg: #1a2228; --color-component-bg-hover: #1d242b; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 379836e4..ed0eab31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ catalogs: react-just-parallax: specifier: ^3.1.16 version: 3.1.16 + react-toastify: + specifier: ^11.0.5 + version: 11.0.5 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -301,6 +304,9 @@ importers: react-just-parallax: specifier: 'catalog:' version: 3.1.16(react@19.1.0) + react-toastify: + specifier: 'catalog:' + version: 11.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.1.2)(react@19.1.0) @@ -5224,6 +5230,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-toastify@11.0.5: + resolution: {integrity: sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==} + peerDependencies: + react: ^18 || ^19 + react-dom: ^18 || ^19 + react@19.1.0: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} @@ -11686,6 +11698,12 @@ snapshots: '@react-types/shared': 3.29.0(react@19.1.0) react: 19.1.0 + react-toastify@11.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + clsx: 2.1.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react@19.1.0: {} read-pkg-up@7.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4aecee00..57eaf623 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,6 +26,7 @@ catalog: '@untitled-theme/icons-react': ^0.14.0 motion: ^12.10.5 react-dom: ^19.0.0 + react-toastify: ^11.0.5 react-just-parallax: ^3.1.16 tailwind-merge: ^3.2.0 tailwind-variants: ^1.0.0