feat: notifications and running game log

This commit is contained in:
LynithDev 2025-08-25 21:16:32 +02:00
parent e2e1cdd616
commit 37296675e4
29 changed files with 818 additions and 104 deletions

12
.vscode/settings.json vendored
View file

@ -2,6 +2,18 @@
"[rust]": { "[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer" "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": { "files.readonlyInclude": {
"**/node_modules/**": true, "**/node_modules/**": true,
"**/*.gen.ts": true "**/*.gen.ts": true

View file

@ -38,6 +38,7 @@
"react-dom": "catalog:", "react-dom": "catalog:",
"react-just-parallax": "catalog:", "react-just-parallax": "catalog:",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-toastify": "catalog:",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"skinview3d": "catalog:", "skinview3d": "catalog:",

View file

@ -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 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 } 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 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<string, never> export type SettingsOsExtra = { enable_gamemode: boolean | null }
export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated" export type Sort = "Relevance" | "Downloads" | "Newest" | "Updated"
@ -230,9 +232,9 @@ export type VersionType =
*/ */
"old_beta" "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":[]}' } 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 = { 'oneclient': { openDevTools: () => Promise<void>, export type Router = { 'folders': { fromCluster: (folderName: string) => Promise<string>,
getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> }, openCluster: (folderName: string) => Promise<null> },
'core': { getClusters: () => Promise<ClusterModel[]>, 'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>, getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>, removeCluster: (id: number) => Promise<null>,
@ -243,6 +245,10 @@ getScreenshots: (id: number) => Promise<string[]>,
getWorlds: (id: number) => Promise<string[]>, getWorlds: (id: number) => Promise<string[]>,
getLogs: (id: number) => Promise<string[]>, getLogs: (id: number) => Promise<string[]>,
getLogByName: (id: number, name: string) => Promise<string | null>, getLogByName: (id: number, name: string) => Promise<string | null>,
getRunningProcesses: () => Promise<Process[]>,
getRunningProcessesByClusterId: (clusterId: number) => Promise<Process[]>,
isClusterRunning: (clusterId: number) => Promise<boolean>,
killProcess: (pid: number) => Promise<null>,
getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>, getProfileOrDefault: (name: string | null) => Promise<SettingProfileModel>,
getGlobalProfile: () => Promise<SettingProfileModel>, getGlobalProfile: () => Promise<SettingProfileModel>,
updateClusterProfile: (name: string, profile: ProfileUpdate) => Promise<SettingProfileModel>, updateClusterProfile: (name: string, profile: ProfileUpdate) => Promise<SettingProfileModel>,
@ -267,8 +273,8 @@ fetchMinecraftProfile: (uuid: string) => Promise<MojangPlayerProfile> },
'events': { ingress: (event: IngressPayload) => Promise<void>, 'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>, message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> }, process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>, 'oneclient': { openDevTools: () => Promise<void>,
openCluster: (folderName: string) => Promise<null> } }; getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>> } };
export type { InferCommandOutput } export type { InferCommandOutput }

View file

@ -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<ButtonProps, 'children' | 'onClick'> & {
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 (
<Button
className={launchButtonVariants({ isRunning, className })}
isDisabled={isDisabled || isRunning}
onClick={launch}
{...rest}
>
{isRunning ? 'Running' : 'Launch'}
</Button>
);
}

View file

@ -1,11 +1,11 @@
@reference "../styles/global.css"; @reference "../styles/global.css";
.log_container { .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 { .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 * { .log * {

View file

@ -1,13 +1,13 @@
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; 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'; import styles from './LogViewer.module.css';
export interface LogViewerProps { export interface LogViewerProps {
content: string; content: string;
scrollRef: React.RefObject<HTMLElement | null>; scrollRef: React.RefObject<HTMLElement | null>;
autoScroll?: boolean; autoScroll?: boolean;
ref?: React.RefObject<LogViewerRef>; ref?: React.Ref<LogViewerRef>;
} }
export interface LogViewerRef { export interface LogViewerRef {
@ -25,6 +25,7 @@ export function LogViewer({
ref, ref,
}: LogViewerProps) { }: LogViewerProps) {
const lines = useMemo<Array<string>>(() => content.split('\n'), [content]); const lines = useMemo<Array<string>>(() => content.split('\n'), [content]);
const containerRef = useRef<HTMLDivElement>(null);
// used to force re-render (and re-init of virtualizer hook) // used to force re-render (and re-init of virtualizer hook)
const [_lastUpdated, setLastUpdated] = useState(Date.now()); const [_lastUpdated, setLastUpdated] = useState(Date.now());
@ -36,21 +37,30 @@ export function LogViewer({
paddingStart: 4, paddingStart: 4,
paddingEnd: 4, paddingEnd: 4,
gap: 0, gap: 0,
overscan: 20, overscan: 8,
scrollMargin: containerRef.current ? containerRef.current.offsetTop + 75 : 0,
}); });
useImperativeHandle(ref, () => ({ const scrollToEnd = useCallback(() => {
push: (line) => { requestAnimationFrame(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }));
const len = lines.push(line); }, [scrollRef]);
setLastUpdated(Date.now());
if (autoScroll) const appendLine = useCallback((line: string) => {
virtualizer.scrollToIndex(len - 1); setLastUpdated(Date.now());
}, lines.push(line);
}), [autoScroll, lines, virtualizer]);
if (autoScroll)
scrollToEnd();
}, [autoScroll, lines, scrollToEnd]);
useImperativeHandle(ref, () => ({
push: appendLine,
scrollToEnd,
}), [appendLine, scrollToEnd]);
return ( return (
<div <div
ref={containerRef}
style={{ style={{
height: `${virtualizer.getTotalSize()}px`, height: `${virtualizer.getTotalSize()}px`,
}} }}
@ -67,7 +77,6 @@ export function LogViewer({
height: '100%', height: '100%',
}} }}
> >
<code className={styles.log}> <code className={styles.log}>
{virtualizer.getVirtualItems().map(virtualRow => ( {virtualizer.getVirtualItems().map(virtualRow => (
<LogLine <LogLine
@ -75,7 +84,7 @@ export function LogViewer({
line={lines[virtualRow.index]} line={lines[virtualRow.index]}
style={{ style={{
height: `${virtualRow.size}px`, height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start - virtualizer.options.scrollMargin}px)`,
}} }}
/> />
))} ))}
@ -109,7 +118,7 @@ function LogLine({
<span data-level={level} style={style}> <span data-level={level} style={style}>
<span data-time={time}>[{time}]</span> <span data-time={time}>[{time}]</span>
<span>[{threadName}/{level}]</span> <span>[{threadName}/{level}]</span>
<span data-chat={isChat}>{isChat ? ' [CHAT] ' : ' '}{line.slice(matched.length)}</span> <span data-chat={isChat ? 'true' : undefined}>{isChat ? ' [CHAT]' : ' '}{line.slice(matched.length)}</span>
<br /> <br />
</span> </span>
); );

View file

@ -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;
}

View file

@ -1,11 +1,13 @@
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import type { VariantProps } from 'tailwind-variants'; import type { VariantProps } from 'tailwind-variants';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
import styles from './Spinner.module.css';
const spinnerVariants = tv({ const spinnerVariants = tv({
base: 'inline-block relative opacity-0 animate-delay-1000 animate-fade animate-fill-forwards', base: styles.loader,
variants: { variants: {
size: { size: {
extraSmall: 'w-6 h-6',
small: 'w-8 h-8', small: 'w-8 h-8',
medium: 'w-12 h-12', medium: 'w-12 h-12',
large: 'w-16 h-16', large: 'w-16 h-16',
@ -24,9 +26,6 @@ export function Spinner({
className, className,
}: SpinnerProps) { }: SpinnerProps) {
return ( return (
<div className={spinnerVariants({ size, className })}> <div className={spinnerVariants({ size, className })}></div>
<div className="absolute w-full h-full rounded-full border-2 border-fg-primary animate-fade-scale"></div>
<div className="absolute w-full h-full rounded-full border-2 border-fg-primary animate-fade-scale animate-delay-500"></div>
</div>
); );
} }

View file

@ -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%;
}
}

View file

@ -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<ToastData>) {
const { container, closeBtn } = toastVariants({
clickable: toastProps.closeOnClick || typeof toastProps.onClick !== 'undefined',
});
return (
<div className={container()}>
<ToastIcon isLoading={toastProps.isLoading} type={toastProps.type} />
<div className="flex flex-col">
<h4 className="text-lg font-medium">{data.title}</h4>
{data.message && <p className="text-sm text-fg-secondary">{data.message}</p>}
</div>
<div className={closeBtn()}>
<Button
color="ghost"
onClick={closeToast}
size="icon"
>
<XCloseIcon />
</Button>
</div>
<div className="w-full h-1 absolute left-0 right-0 bottom-0">
<ToastProgressBar closeToast={closeToast} isPaused={isPaused} toastProps={toastProps} />
</div>
</div>
);
}
function ToastProgressBar({
isPaused,
toastProps,
closeToast,
}: {
toastProps: ToastOptions;
isPaused: boolean;
closeToast: () => void;
}) {
const attrs: HTMLAttributes<HTMLDivElement> = {};
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 (
<div
className="h-1 bg-brand"
{...attrs}
>
</div>
);
}
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<string, ToastId> = 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 (
<ToastContainer
newestOnTop
position="bottom-right"
transition={cssTransition({
enter: 'animate-fade animate-duration-75',
exit: 'animate-fade animate-duration-75 animate-reverse',
})}
/>
);
}
function ToastIcon({
isLoading,
type,
}: {
isLoading: boolean | undefined;
type: string;
}) {
if (isLoading)
return <Spinner size="extraSmall" />;
switch (type) {
case 'success':
return <CheckCircleIcon className="w-6 h-6 text-success" />;
case 'warning':
return <AlertTriangleIcon className="w-6 h-6 text-code-warn" />;
case 'error':
return <AlertCircleIcon className="w-6 h-6 text-danger" />;
case 'info':
default:
return <InfoCircleIcon className="w-6 h-6 text-code-info" />;
}
}

View file

@ -2,3 +2,4 @@ export * from './AccountPopup';
export * from './AddAccountModal'; export * from './AddAccountModal';
export * from './Overlay'; export * from './Overlay';
export * from './Popup'; export * from './Popup';
export * from './Toasts';

View file

@ -1,6 +1,8 @@
import { bindings } from '@/main'; import { bindings } from '@/main';
import useAppShellStore from '@/stores/appShellStore'; 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() { export function useLastPlayedClusters() {
return useCommandSuspense( return useCommandSuspense(
@ -31,3 +33,41 @@ export function useActiveCluster() {
return clusters.find(c => c.id === clusterId) ?? clusters.at(0); 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;
}

View file

@ -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;
}

View file

@ -1,5 +1,6 @@
import type { QueryClient } from '@tanstack/react-query'; import type { QueryClient } from '@tanstack/react-query';
import type { NavigateOptions, ToOptions } from '@tanstack/react-router'; import type { NavigateOptions, ToOptions } from '@tanstack/react-router';
import { Toasts } from '@/components/overlay';
import { TanStackDevtools } from '@tanstack/react-devtools'; import { TanStackDevtools } from '@tanstack/react-devtools';
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools';
import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router'; import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router';
@ -24,23 +25,27 @@ export const Route = createRootRouteWithContext<AppRouterContext>()({
function RootRoute() { function RootRoute() {
const router = useRouter(); const router = useRouter();
return ( return (
// <AnimatedOutletProvider>
<RouterProvider <RouterProvider
navigate={(to, options) => router.navigate({ to, ...options })} navigate={(to, options) => router.navigate({ to, ...options })}
useHref={to => router.buildLocation({ to }).href} useHref={to => router.buildLocation({ to }).href}
> >
<DevTools /> <DevTools />
<div className="h-screen flex flex-col overflow-hidden text-fg-primary"> <div className="h-screen flex flex-col overflow-hidden text-fg-primary">
<Outlet /> <Outlet />
</div> </div>
<Toasts />
</RouterProvider> </RouterProvider>
// </AnimatedOutletProvider>
); );
} }
function DevTools() { function DevTools() {
return ( return (
<TanStackDevtools <TanStackDevtools
config={{
position: 'top-left',
}}
plugins={[ plugins={[
{ {
name: 'Tanstack Query', name: 'Tanstack Query',

View file

@ -51,7 +51,6 @@ function LogContent({
}) { }) {
const { cluster } = Route.useRouteContext(); const { cluster } = Route.useRouteContext();
// TODO: Fix cache
const { data: content, error, isLoading } = useCommand( const { data: content, error, isLoading } = useCommand(
['getLogByName', cluster.id, fileName], ['getLogByName', cluster.id, fileName],
() => bindings.core.getLogByName(cluster.id, fileName), () => bindings.core.getLogByName(cluster.id, fileName),

View file

@ -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 (
<SheetPage.Content>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center">
<h2 className="flex-1 text-xxl font-semibold">Game Log</h2>
</div>
<LogContent scrollRef={scrollContainerRef} />
</div>
</SheetPage.Content>
);
}
function LogContent({
scrollRef,
}: {
scrollRef: React.RefObject<HTMLElement | null>;
}) {
const { cluster } = Route.useRouteContext();
const logViewerRef = useRef<LogViewerRef>(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 (
<LoaderContainer loading={isLoading}>
<LogViewer
autoScroll
content={getMessageFromError(error) || content || 'Empty log file'}
ref={logViewerRef}
scrollRef={scrollRef}
/>
</LoaderContainer>
);
}

View file

@ -1,5 +1,7 @@
import { LoaderSuspense, Tab, TabList } from '@/components'; import { LoaderSuspense, Tab, TabList } from '@/components';
import { LaunchButton } from '@/components/LaunchButton';
import { SheetPage } from '@/components/SheetPage'; import { SheetPage } from '@/components/SheetPage';
import { useIsRunning } from '@/hooks/useClusters';
import { bindings } from '@/main'; import { bindings } from '@/main';
import { prettifyLoader } from '@/utils/loaders'; import { prettifyLoader } from '@/utils/loaders';
import { getVersionInfoOrDefault } from '@/utils/versionMap'; import { getVersionInfoOrDefault } from '@/utils/versionMap';
@ -40,9 +42,12 @@ export const Route = createFileRoute('/app/cluster')({
}); });
function RouteComponent() { function RouteComponent() {
const { cluster } = Route.useRouteContext();
const tabListRef = useRef<HTMLDivElement>(null); const tabListRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLElement>(null); const scrollContainerRef = useRef<HTMLElement>(null);
const isRunning = useIsRunning(cluster.id);
const { scrollYProgress } = useScroll({ const { scrollYProgress } = useScroll({
axis: 'y', axis: 'y',
container: scrollContainerRef, container: scrollContainerRef,
@ -68,6 +73,8 @@ function RouteComponent() {
<Tab from={Route.id} search={search} to="/app/cluster/overview">Overview</Tab> <Tab from={Route.id} search={search} to="/app/cluster/overview">Overview</Tab>
<Tab from={Route.id} search={search} to="/app/cluster/logs">Logs</Tab> <Tab from={Route.id} search={search} to="/app/cluster/logs">Logs</Tab>
<Tab from={Route.id} search={{ ...search, packageType: 'mod' }} to="/app/cluster/browser">Browser</Tab> <Tab from={Route.id} search={{ ...search, packageType: 'mod' }} to="/app/cluster/browser">Browser</Tab>
{isRunning && <span className="flex-1"></span>}
{isRunning && <Tab from={Route.id} search={search} to="/app/cluster/process">Process</Tab>}
</TabList> </TabList>
<div className="relative pb-8"> <div className="relative pb-8">
@ -85,8 +92,6 @@ function HeaderLarge() {
const openFolder = () => bindings.folders.openCluster(cluster.folder_name); const openFolder = () => bindings.folders.openCluster(cluster.folder_name);
const launch = () => bindings.core.launchCluster(cluster.id, null);
return ( return (
<div className="flex flex-row items-end gap-16"> <div className="flex flex-row items-end gap-16">
<div className="flex-1 flex flex-col gap-2"> <div className="flex-1 flex flex-col gap-2">
@ -110,13 +115,10 @@ function HeaderLarge() {
<FolderIcon /> <FolderIcon />
</Button> </Button>
<Button <LaunchButton
color="primary" clusterId={cluster.id}
onPress={launch}
size="large" size="large"
> />
Launch
</Button>
</div> </div>
</div> </div>
); );
@ -127,8 +129,6 @@ function HeaderSmall() {
const openFolder = () => bindings.folders.openCluster(cluster.folder_name); const openFolder = () => bindings.folders.openCluster(cluster.folder_name);
const launch = () => bindings.core.launchCluster(cluster.id, null);
return ( return (
<div className="flex flex-row justify-between items-center h-full"> <div className="flex flex-row justify-between items-center h-full">
<h1 className="text-2lg h-full font-medium">{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}</h1> <h1 className="text-2lg h-full font-medium">{prettifyLoader(cluster.mc_loader)} {cluster.mc_version}</h1>
@ -142,12 +142,10 @@ function HeaderSmall() {
<FolderIcon /> <FolderIcon />
</Button> </Button>
<Button <LaunchButton
clusterId={cluster.id}
color="primary" color="primary"
onPress={launch} />
>
Launch
</Button>
</div> </div>
</div> </div>
); );

View file

@ -1,5 +1,6 @@
import type { GameLoader } from '@/bindings.gen'; import type { GameLoader } from '@/bindings.gen';
import { GameBackground } from '@/components'; import { GameBackground } from '@/components';
import { LaunchButton } from '@/components/LaunchButton';
import { SheetPage } from '@/components/SheetPage'; import { SheetPage } from '@/components/SheetPage';
import { bindings } from '@/main'; import { bindings } from '@/main';
import useClusterStore from '@/stores/clusterStore'; import useClusterStore from '@/stores/clusterStore';
@ -92,6 +93,18 @@ function RouteComponent() {
const versionInfo = useMemo(() => getVersionInfoOrDefault(cluster?.mc_version), [cluster]); 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 ( return (
<SheetPage <SheetPage
headerLarge={<HeaderLarge />} headerLarge={<HeaderLarge />}
@ -183,21 +196,12 @@ function RouteComponent() {
)} )}
<div className="w-full flex flex-row gap-4 mt-2"> <div className="w-full flex flex-row gap-4 mt-2">
<Button className="flex-1" size="large">Launch</Button> <LaunchButton className="flex-1" clusterId={cluster?.id} size="large" />
<Button <Button
className="flex-1" className="flex-1"
color="secondary" color="secondary"
onPress={() => { onPress={view}
if (!cluster)
return;
navigate({
to: `/app/cluster/overview`,
search: {
clusterId: cluster.id,
},
});
}}
size="large" size="large"
> >
View View

View file

@ -1,11 +1,11 @@
import type { ClusterModel, GameLoader } from '@/bindings.gen'; import type { ClusterModel, GameLoader } from '@/bindings.gen';
import type { ButtonProps } from 'react-aria-components'; import type { ButtonProps } from 'react-aria-components';
import { GameBackground } from '@/components'; import { GameBackground } from '@/components';
import { LaunchButton } from '@/components/LaunchButton';
import { useActiveCluster, useLastPlayedClusters } from '@/hooks/useClusters'; import { useActiveCluster, useLastPlayedClusters } from '@/hooks/useClusters';
import { bindings } from '@/main';
import useAppShellStore from '@/stores/appShellStore'; import useAppShellStore from '@/stores/appShellStore';
import { prettifyLoader } from '@/utils/loaders'; import { prettifyLoader } from '@/utils/loaders';
import { animations, transitions } from '@/utils/motion'; import { animations } from '@/utils/motion';
import { getVersionInfo, getVersionInfoOrDefault } from '@/utils/versionMap'; import { getVersionInfo, getVersionInfoOrDefault } from '@/utils/versionMap';
import { Button } from '@onelauncher/common/components'; import { Button } from '@onelauncher/common/components';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
@ -65,13 +65,6 @@ function ActiveClusterInfo({
from: Route.id, from: Route.id,
}); });
const launch = () => {
if (!cluster)
return;
bindings.core.launchCluster(cluster.id, null);
};
const viewCluster = () => { const viewCluster = () => {
if (!cluster) if (!cluster)
return; return;
@ -86,16 +79,24 @@ function ActiveClusterInfo({
return ( return (
<motion.div <motion.div
{...animations.slideInLeft} animate={{
position: 'relative',
left: '0',
}}
className="flex flex-1 flex-col justify-center items-start gap-2" className="flex flex-1 flex-col justify-center items-start gap-2"
initial={{
position: 'relative',
left: '-50%',
}}
key={(cluster?.mc_version ?? Math.random()) + (cluster?.mc_loader ?? '')} key={(cluster?.mc_version ?? Math.random()) + (cluster?.mc_loader ?? '')}
transition={{ ...transitions.spring, delay: 0.2 }} transition={{ ease: 'backInOut', duration: 0.7 }}
> >
<h1 className="text-6xl font-bold text-fg-primary">{cluster?.mc_version} {prettifyLoader(cluster?.mc_loader ?? 'vanilla')}</h1> <h1 className="text-6xl font-bold text-fg-primary">{cluster?.mc_version} {prettifyLoader(cluster?.mc_loader ?? 'vanilla')}</h1>
<p className="text-lg font-medium text-fg-secondary">{versionInfo.shortDescription}</p> <p className="text-lg font-medium text-fg-secondary">{versionInfo.shortDescription}</p>
<div className="flex flex-row justify-center items-center gap-2"> <div className="flex flex-row justify-center items-center gap-2">
<Button onPress={launch} size="large">Launch</Button> <LaunchButton clusterId={cluster?.id} size="large" />
<Button color="ghost" onPress={viewCluster} size="iconLarge"> <Button color="ghost" onPress={viewCluster} size="iconLarge">
<Settings04Icon /> <Settings04Icon />
</Button> </Button>

View file

@ -1,15 +1,4 @@
@layer utilities { @layer utilities {
@keyframes custom-anim-fade-scale {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@keyframes custom-anim-zoom { @keyframes custom-anim-zoom {
0% { 0% {
transform: scale(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 { @utility animate-zoom {
transform: scale(0); 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; 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;

View file

@ -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<ToastOptions>,
) {
const objData: UpdateOptions<Record<string, unknown>> = {
progress,
isLoading,
};
for (const [key, value] of Object.entries<unknown>(data)) {
if (value === undefined || value === null)
continue;
if (!objData.data)
objData.data = {};
objData.data[key] = value;
}
toastify.update(toastId, objData);
}

View file

@ -1,13 +1,82 @@
use chrono::{DateTime, Utc};
use serde::Serialize; 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; use crate::store::processes::Process;
#[onelauncher_macro::specta] #[onelauncher_macro::specta]
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")] pub struct ProcessPayload {
pub enum ProcessPayload { pub cluster_id: ClusterId,
Starting { command: String }, pub kind: ProcessPayloadKind,
Started { process: Process }, }
Stopped { pid: u32, exit_code: i32 },
Output { pid: u32, output: String }, #[onelauncher_macro::specta]
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum ProcessPayloadKind {
Starting {
command: String,
},
Started {
pid: u32,
started_at: DateTime<Utc>,
post_hook: Option<String>,
account_id: Uuid,
},
Stopped {
pid: u32,
exit_code: i32,
},
Output {
pid: u32,
output: String,
},
}
pub async fn get_running_processes() -> LauncherResult<Vec<Process>> {
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<Vec<Process>> {
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<bool> {
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(())
} }

View file

@ -3,6 +3,7 @@ use crate::api::packages::data::{
SearchResult, SearchResult,
}; };
use crate::api::packages::provider::ProviderExt; use crate::api::packages::provider::ProviderExt;
use crate::store::processes::Process;
use crate::store::{Settings, State}; use crate::store::{Settings, State};
use crate::utils::pagination::Paginated; use crate::utils::pagination::Paginated;
use interpulse::api::minecraft::Version; use interpulse::api::minecraft::Version;
@ -55,6 +56,21 @@ pub trait TauriLauncherApi {
#[taurpc(alias = "getLogByName")] #[taurpc(alias = "getLogByName")]
async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult<Option<String>>; async fn get_log_by_name(id: ClusterId, name: String) -> LauncherResult<Option<String>>;
// MARK: API: processes
#[taurpc(alias = "getRunningProcesses")]
async fn get_running_processes() -> LauncherResult<Vec<Process>>;
#[taurpc(alias = "getRunningProcessesByClusterId")]
async fn get_running_processes_by_cluster_id(
cluster_id: ClusterId,
) -> LauncherResult<Vec<Process>>;
#[taurpc(alias = "isClusterRunning")]
async fn is_cluster_running(cluster_id: ClusterId) -> LauncherResult<bool>;
#[taurpc(alias = "killProcess")]
async fn kill_process(pid: u32) -> LauncherResult<()>;
// MARK: API: profiles // MARK: API: profiles
#[taurpc(alias = "getProfileOrDefault")] #[taurpc(alias = "getProfileOrDefault")]
async fn get_profile_or_default(name: Option<String>) -> LauncherResult<SettingsProfile>; async fn get_profile_or_default(name: Option<String>) -> LauncherResult<SettingsProfile>;
@ -322,6 +338,26 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
api::cluster::content::get_log_by_name(&cluster, &name).await api::cluster::content::get_log_by_name(&cluster, &name).await
} }
// MARK: Impl: processes
async fn get_running_processes(self) -> LauncherResult<Vec<Process>> {
api::processes::get_running_processes().await
}
async fn get_running_processes_by_cluster_id(
self,
cluster_id: ClusterId,
) -> LauncherResult<Vec<Process>> {
api::processes::get_running_processes_by_cluster_id(cluster_id).await
}
async fn is_cluster_running(self, cluster_id: ClusterId) -> LauncherResult<bool> {
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 // MARK: Impl: profiles
async fn get_global_profile(self) -> SettingsProfile { async fn get_global_profile(self) -> SettingsProfile {
api::setting_profiles::get_global_profile().await api::setting_profiles::get_global_profile().await

View file

@ -15,7 +15,7 @@ use uuid::Uuid;
use crate::api::cluster; use crate::api::cluster;
use crate::api::cluster::dao::ClusterId; use crate::api::cluster::dao::ClusterId;
use crate::api::game::log::{CensorMap, censor_line}; 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::api::proxy::event::{LauncherEvent, send_event};
use crate::error::LauncherResult; use crate::error::LauncherResult;
use crate::send_error; use crate::send_error;
@ -176,8 +176,11 @@ impl ProcessStore {
settings: &setting_profiles::Model, settings: &setting_profiles::Model,
mut command: Command, mut command: Command,
) -> LauncherResult<Arc<RwLock<Process>>> { ) -> LauncherResult<Arc<RwLock<Process>>> {
send_event(LauncherEvent::Process(ProcessPayload::Starting { send_event(LauncherEvent::Process(ProcessPayload {
command: format!("{command:?}"), cluster_id,
kind: ProcessPayloadKind::Starting {
command: format!("{command:?}"),
},
})) }))
.await; .await;
@ -224,9 +227,9 @@ impl ProcessStore {
} { } {
censor_line(&censors, &mut line); censor_line(&censors, &mut line);
send_event(LauncherEvent::Process(ProcessPayload::Output { send_event(LauncherEvent::Process(ProcessPayload {
pid, cluster_id,
output: line, kind: ProcessPayloadKind::Output { pid, output: line },
})) }))
.await; .await;
} }
@ -251,6 +254,18 @@ impl ProcessStore {
let process = Arc::new(RwLock::new(process)); let process = Arc::new(RwLock::new(process));
let mut write_guard = self.processes.write().await; let mut write_guard = self.processes.write().await;
write_guard.insert(pid, process.clone()); 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); tracing::info!("spawned process with pid {}", pid);
Ok(process) Ok(process)
@ -285,9 +300,9 @@ impl ProcessStore {
send_error!("{err:?}"); send_error!("{err:?}");
} }
send_event(LauncherEvent::Process(ProcessPayload::Stopped { send_event(LauncherEvent::Process(ProcessPayload {
pid, cluster_id,
exit_code, kind: ProcessPayloadKind::Stopped { pid, exit_code },
})) }))
.await; .await;

View file

@ -1,2 +1,3 @@
export * from './useAsyncEffect';
export * from './useCommand'; export * from './useCommand';
export * from './usePagination'; export * from './usePagination';

View file

@ -0,0 +1,19 @@
import type React from 'react';
import { useEffect } from 'react';
type UnlistenFn = (() => any) | undefined;
export function useAsyncEffect(cb: () => Promise<UnlistenFn>, 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);
}

View file

@ -23,7 +23,7 @@
--color-brand: #2b4bff; --color-brand: #2b4bff;
--color-brand-hover: #2843dd; --color-brand-hover: #2843dd;
--color-brand-pressed: #3957ff; --color-brand-pressed: #3957ff;
--color-brand-disabled: #2543eb; --color-brand-disabled: #1f2f81;
--color-component-bg: #1a2228; --color-component-bg: #1a2228;
--color-component-bg-hover: #1d242b; --color-component-bg-hover: #1d242b;

18
pnpm-lock.yaml generated
View file

@ -105,6 +105,9 @@ catalogs:
react-just-parallax: react-just-parallax:
specifier: ^3.1.16 specifier: ^3.1.16
version: 3.1.16 version: 3.1.16
react-toastify:
specifier: ^11.0.5
version: 11.0.5
rimraf: rimraf:
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.0.1 version: 6.0.1
@ -301,6 +304,9 @@ importers:
react-just-parallax: react-just-parallax:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.1.16(react@19.1.0) 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: react-markdown:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0(@types/react@19.1.2)(react@19.1.0) version: 10.1.0(@types/react@19.1.2)(react@19.1.0)
@ -5224,6 +5230,12 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 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: react@19.1.0:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -11686,6 +11698,12 @@ snapshots:
'@react-types/shared': 3.29.0(react@19.1.0) '@react-types/shared': 3.29.0(react@19.1.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: {} react@19.1.0: {}
read-pkg-up@7.0.1: read-pkg-up@7.0.1:

View file

@ -26,6 +26,7 @@ catalog:
'@untitled-theme/icons-react': ^0.14.0 '@untitled-theme/icons-react': ^0.14.0
motion: ^12.10.5 motion: ^12.10.5
react-dom: ^19.0.0 react-dom: ^19.0.0
react-toastify: ^11.0.5
react-just-parallax: ^3.1.16 react-just-parallax: ^3.1.16
tailwind-merge: ^3.2.0 tailwind-merge: ^3.2.0
tailwind-variants: ^1.0.0 tailwind-variants: ^1.0.0