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 1/2] 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 From bd009c16776f05cc0ef67f9381200f64b232b19d Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:11:09 +0200 Subject: [PATCH 2/2] chore: small fixes and tweaks + external link now has confirmation dialog --- apps/oneclient/frontend/package.json | 1 - apps/oneclient/frontend/src/bindings.gen.ts | 5 +- .../frontend/src/components/ExternalLink.tsx | 81 ++++++++ .../src/components/Markdown.module.css | 148 ++++++++++++++ .../frontend/src/components/Markdown.tsx | 28 +++ .../frontend/src/components/PackageItem.tsx | 57 ++---- .../src/components/overlay/Overlay.tsx | 32 ++- .../src/routes/app/cluster/browser/index.tsx | 69 ++++--- .../routes/app/cluster/browser/package.tsx | 186 +++++++++--------- packages/core/src/api/tauri/commands.rs | 9 + 10 files changed, 452 insertions(+), 164 deletions(-) create mode 100644 apps/oneclient/frontend/src/components/ExternalLink.tsx create mode 100644 apps/oneclient/frontend/src/components/Markdown.module.css create mode 100644 apps/oneclient/frontend/src/components/Markdown.tsx diff --git a/apps/oneclient/frontend/package.json b/apps/oneclient/frontend/package.json index 93908972..e4e50039 100644 --- a/apps/oneclient/frontend/package.json +++ b/apps/oneclient/frontend/package.json @@ -28,7 +28,6 @@ "@tauri-apps/api": "catalog:", "@tauri-apps/plugin-clipboard-manager": "catalog:", "@tauri-apps/plugin-dialog": "catalog:", - "@tauri-apps/plugin-opener": "catalog:", "@untitled-theme/icons-react": "catalog:", "motion": "catalog:", "overlayscrollbars": "catalog:", diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 6ef73ffb..425e76b7 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -232,7 +232,7 @@ export type VersionType = */ "old_beta" -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":[]}' } +const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'core':'{"createCluster":["options"],"getGlobalProfile":[],"getLoadersForVersion":["mc_version"],"getScreenshots":["id"],"getUsers":[],"getPackage":["provider","slug"],"getDefaultUser":["fallback"],"getPackageBody":["provider","body"],"writeSettings":["setting"],"open":["input"],"isClusterRunning":["cluster_id"],"getMultiplePackages":["provider","slugs"],"killProcess":["pid"],"updateClusterProfile":["name","profile"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"getLogByName":["id","name"],"removeUser":["uuid"],"fetchMinecraftProfile":["uuid"],"openMsaLogin":[],"getProfileOrDefault":["name"],"getUser":["uuid"],"getClusters":[],"getWorlds":["id"],"launchCluster":["id","uuid"],"readSettings":[],"removeCluster":["id"],"searchPackages":["provider","query"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"getGameVersions":[],"setDefaultUser":["uuid"],"getClusterById":["id"],"updateClusterById":["id","request"],"getRunningProcessesByClusterId":["cluster_id"],"getLogs":["id"],"getRunningProcesses":[]}', 'oneclient':'{"openDevTools":[],"getClustersGroupedByMajor":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}' } export type Router = { 'folders': { fromCluster: (folderName: string) => Promise, openCluster: (folderName: string) => Promise }, 'core': { getClusters: () => Promise, @@ -269,7 +269,8 @@ getMultiplePackages: (provider: Provider, slugs: string[]) => Promise Promise>, downloadPackage: (provider: Provider, packageId: string, versionId: string, clusterId: number, skipCompatibility: boolean | null) => Promise, getUsersFromAuthor: (provider: Provider, author: PackageAuthor) => Promise, -fetchMinecraftProfile: (uuid: string) => Promise }, +fetchMinecraftProfile: (uuid: string) => Promise, +open: (input: string) => Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, process: (event: ProcessPayload) => Promise }, diff --git a/apps/oneclient/frontend/src/components/ExternalLink.tsx b/apps/oneclient/frontend/src/components/ExternalLink.tsx new file mode 100644 index 00000000..0dd9dad5 --- /dev/null +++ b/apps/oneclient/frontend/src/components/ExternalLink.tsx @@ -0,0 +1,81 @@ +import type React from 'react'; +import type { HTMLProps } from 'react'; +import { bindings } from '@/main'; +import { LinkExternal01Icon } from '@untitled-theme/icons-react'; +import { useState } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { Overlay } from './overlay'; + +export interface ExternalLinkProps extends HTMLProps { + href?: string | undefined; + children?: React.ReactNode; + includeIcon?: boolean | undefined; + showModal?: boolean | undefined; +} + +export function ExternalLink({ + href, + children, + includeIcon, + showModal = true, + className, + ...rest +}: ExternalLinkProps) { + const [isOpen, setIsOpen] = useState(false); + + // done like this so that "image" links don't have an icon + const withIcon = !!(includeIcon ?? typeof children === 'string'); + + return ( + + { + e.preventDefault(); + if (!href) + return; + + if (showModal) + setIsOpen(true); + else + bindings.core.open(href); + }} + role="button" + {...rest} + > + {children} + {withIcon && } + + + + + External Link +

Are you sure you want to open this link in your browser?

+ + {href} + + { + if (href) + bindings.core.open(href); + + setIsOpen(false); + }, + }, + ]} + /> +
+
+
+ ); +} diff --git a/apps/oneclient/frontend/src/components/Markdown.module.css b/apps/oneclient/frontend/src/components/Markdown.module.css new file mode 100644 index 00000000..714ab4f3 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Markdown.module.css @@ -0,0 +1,148 @@ +.markdown-body h1:first-child { + margin-block-start: 0; + padding-block-start: 0; +} + .markdown-body blockquote, .markdown-body details, .markdown-body dl, .markdown-body ol, .markdown-body p, .markdown-body code, .markdown-body pre, .markdown-body table, .markdown-body ul { + margin-top: 0; + margin-bottom: 16px; +} + .markdown-body li, .markdown-body p { + padding: 0; + line-height: 1.5; +} + .markdown-body h1 { + display: block; +} + .markdown-body h1, .markdown-body h2, .markdown-body h3 { + color: var(--color-contrast); +} + .markdown-body h1, .markdown-body h2 { + padding: 10px 0 5px; + border-bottom: 1px solid var(--color-divider); +} + .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6, .markdown-body li, .markdown-body p { + word-wrap: break-word; + overflow-wrap: anywhere; +} + .markdown-body blockquote { + padding: 0 1em; + color: var(--color-base); + border-left: 0.25em solid var(--color-button-bg); + margin-inline: 0; +} + .markdown-body a { + cursor: pointer; + color: var(--color-blue); +} + .markdown-body a:focus-visible, .markdown-body a:hover { + filter: brightness(1.2); + text-decoration: none; +} + .markdown-body a:active { + filter: brightness(1.1); + text-decoration: none; +} + .markdown-body a:active > img { + scale: 0.98; +} + .markdown-body img { + max-width: 100%; + height: auto; + display: inline-block; +} + .markdown-body html:not(.dark-mode, .oled-mode)selfimg[src$='#gh-dark-mode-only'] { + display: none; +} + .markdown-body html:is(.dark-mode, .oled-mode)selfimg[src$='#gh-light-mode-only'] { + display: none; +} + .markdown-body pre { + margin-top: 1rem; + padding: 14px; + border-radius: var(--radius-sm); + background-color: var(--color-button-bg); + overflow-x: auto; +} + .markdown-body pre code { + font-size: 80%; + padding: 0; + border-radius: 0; +} + .markdown-body code { + padding: 0.2em 0.4em; + font-size: 80%; + border-radius: var(--radius-sm); + background-color: var(--color-button-bg); + color: var(--color-contrast); +} + .markdown-body hr { + margin: 20px 0; + color: var(--color-button-bg); +} + .markdown-body table { + display: block; + width: max-content; + max-width: 100%; + overflow: auto; + border-collapse: separate; + border-spacing: 0; + line-height: 1.5; + border: 0.1rem solid var(--color-button-bg); + border-radius: var(--radius-sm); +} + .markdown-body table th { + font-weight: 600; +} + .markdown-body table td, .markdown-body table th { + padding: 0.4rem 0.85rem; +} + .markdown-body table tr:nth-child(2n) { + background-color: var(--color-accent-contrast); +} + .markdown-body table td:not(:last-of-type), .markdown-body table th:not(:last-of-type) { + border-right: 0.1rem solid var(--color-button-bg); +} + .markdown-body table tr:not(:last-of-type) td, .markdown-body table th { + border-bottom: 0.1rem solid var(--color-button-bg); +} + .markdown-body details { + border: 0.15rem solid var(--color-button-bg); + border-radius: var(--radius-sm); + padding: 0.5rem 0.5rem 0; +} + .markdown-body details summary { + font-weight: bold; + margin: -0.5rem -0.5rem 0; + padding: 0.5rem 0.8rem; + cursor: pointer; + background-color: var(--color-button-bg); + border-radius: var(--radius-xs); +} + .markdown-body details summary:hover { + filter: brightness(0.85); +} + .markdown-body details[open] { + padding: 0.5rem; +} + .markdown-body details[open] summary { + margin-bottom: 0.5rem; + border-radius: var(--radius-xs) var(--radius-xs) 0 0; +} + .markdown-body details[open] > :last-child:not(summary) { + margin-bottom: 0 !important; +} + .markdown-body > :last-child { + margin-bottom: 0 !important; +} + .markdown-body iframe, .markdown-body video { + aspect-ratio: 1.7777777778; + width: 850px; + max-width: 100%; + height: auto; +} + @media screen and (max-width: 1280px) { + .markdown-body iframe, .markdown-body video { + width: 100%; + } +} + \ No newline at end of file diff --git a/apps/oneclient/frontend/src/components/Markdown.tsx b/apps/oneclient/frontend/src/components/Markdown.tsx new file mode 100644 index 00000000..6b598756 --- /dev/null +++ b/apps/oneclient/frontend/src/components/Markdown.tsx @@ -0,0 +1,28 @@ +import ReactMarkdown from 'react-markdown'; +import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; +import { twMerge } from 'tailwind-merge'; +import { ExternalLink } from './ExternalLink'; +import styles from './Markdown.module.css'; + +export function Markdown({ + body, + className, +}: { + body: string; + className?: string | undefined; +}) { + return ( +
+ , + }} + rehypePlugins={[rehypeRaw]} + remarkPlugins={[remarkGfm]} + > + {body} + +
+ ); +} diff --git a/apps/oneclient/frontend/src/components/PackageItem.tsx b/apps/oneclient/frontend/src/components/PackageItem.tsx index 0ff0fe49..fd8985b9 100644 --- a/apps/oneclient/frontend/src/components/PackageItem.tsx +++ b/apps/oneclient/frontend/src/components/PackageItem.tsx @@ -1,5 +1,4 @@ import type { Provider, SearchResult } from '@/bindings.gen'; -import { Show, Tooltip } from '@onelauncher/common/components'; import { Link } from '@tanstack/react-router'; export function PackageGrid({ items, provider, clusterId }: { items: Array; provider: Provider; clusterId: number }) { @@ -21,6 +20,7 @@ export function PackageItem({ provider, clusterId, ...item }: SearchResult & { p return ( - - )} - when={item.icon_url} - > - {`Icon - {`Icon - - {/* - -
- {loaders.toSpliced(loaders.length > 3 ? 2 : 3).map(loader => )} - {loaders.length > 3 && ( -
- - + - {loaders.length - 2} - -
- )} -
-
-
*/} + {item.icon_url + ? ( + <> + {`Icon + {`Icon + + ) + : ( +
+ )}
@@ -74,15 +60,6 @@ export function PackageItem({ provider, clusterId, ...item }: SearchResult & { p

{item.description}

- - {/*
- -
- - {abbreviateNumber(item.downloads)} -
-
-
*/}
); diff --git a/apps/oneclient/frontend/src/components/overlay/Overlay.tsx b/apps/oneclient/frontend/src/components/overlay/Overlay.tsx index 28efd91a..b2899277 100644 --- a/apps/oneclient/frontend/src/components/overlay/Overlay.tsx +++ b/apps/oneclient/frontend/src/components/overlay/Overlay.tsx @@ -1,6 +1,8 @@ +import type { ButtonProps } from '@onelauncher/common/components'; +import type { HTMLAttributes } from 'react'; import { Button } from '@onelauncher/common/components'; import { XCloseIcon } from '@untitled-theme/icons-react'; -import { Dialog, Modal, ModalOverlay } from 'react-aria-components'; +import { Dialog, DialogTrigger, Modal, ModalOverlay } from 'react-aria-components'; import { twMerge } from 'tailwind-merge'; import { tv } from 'tailwind-variants'; @@ -51,6 +53,34 @@ export function Overlay({ ); } +Overlay.Trigger = DialogTrigger; + +Overlay.Buttons = function OverlayButtons({ + className, + buttons, +}: Omit, 'children'> & { + buttons?: Array; +}) { + return ( +
= 3 ? 'flex-col' : 'flex-row', + className, + )} + > + {buttons?.map(({ key, size, color, ...rest }) => ( +
+ ); +}; + Overlay.Dialog = function OverlayDialog({ className, children, diff --git a/apps/oneclient/frontend/src/routes/app/cluster/browser/index.tsx b/apps/oneclient/frontend/src/routes/app/cluster/browser/index.tsx index 0d8af0f2..01e8f954 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/browser/index.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/browser/index.tsx @@ -1,13 +1,14 @@ -import type { Filters, PackageCategories, PackageType, Provider } from '@/bindings.gen'; +import type { Filters, PackageCategories, PackageType, Provider, Sort } from '@/bindings.gen'; import { LoaderSuspense } from '@/components'; import { PackageGrid } from '@/components/PackageItem'; import { bindings } from '@/main'; import { browserCategories, categoryNameFromId } from '@/utils/browser'; import { useCommandSuspense, usePagination } from '@onelauncher/common'; -import { Dropdown, Show, TextField } from '@onelauncher/common/components'; +import { Dropdown, TextField } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; import { SearchMdIcon } from '@untitled-theme/icons-react'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; interface BrowserSearchRouteSearchParams { query?: string; @@ -46,13 +47,10 @@ function RouteComponent() {
- + -
- - {/* */}
@@ -60,14 +58,16 @@ function RouteComponent() { } function BrowserCategories() { - const search = Route.useSearch(); - const { categories, packageType } = search; + const { categories, packageType } = Route.useSearch(); + const navigate = Route.useNavigate(); const navRef = useRef(navigate); + const switchCategory = useMemo(() => (category: string) => { const newCategories = categories.includes(category) ? categories.filter(element => element !== category) : [...categories, category]; + navRef.current({ search: prev => ({ ...prev, @@ -78,23 +78,22 @@ function BrowserCategories() { return (
- {/*
*/}
- -
-
Categories
- {browserCategories[categoryNameFromId[packageType]].map(category => ( -

switchCategory(category)} - > - {category} -

- ))} -
-
+
+
Categories
+ {browserCategories[categoryNameFromId[packageType]].map(category => ( +

switchCategory(category)} + > + {category} +

+ ))} +
); @@ -104,10 +103,13 @@ function BrowserToolbar() { const search = Route.useSearch(); const { provider, query } = search; const navigate = Route.useNavigate(); + const setQuery = (query: string) => { navigate({ search: { ...search, query } }); }; + const [searchBar, setSearchBar] = useState(query); + return (
@@ -150,17 +152,18 @@ interface SearchQuery { query: string | null; offset: number | null; limit: number; - sort: 'Relevance' | 'Downloads' | 'Newest' | 'Updated' | null; + sort: Sort; filters: Filters | null; } const itemsPerPage = 25; -function SearchResult() { +function SearchResults() { const search = Route.useSearch(); const { cluster } = Route.useRouteContext(); const navigate = Route.useNavigate(); const navRef = useRef(navigate); + const query = useMemo(() => ({ query: search.query ?? null, offset: (search.page - 1) * itemsPerPage, @@ -173,30 +176,38 @@ function SearchResult() { package_type: search.packageType, }, }), [cluster, search]); + const results = useCommandSuspense(['searchPackages', search], () => bindings.core.searchPackages( search.provider, query, )); + const pagination = usePagination({ itemsPerPage: query.limit, itemsCount: results.data.total, }); - const [oldTotal, setOldTotal] = useState(pagination.totalPages); + + const oldTotal = useRef(pagination.totalPages); const paginationRef = useRef(pagination); + useEffect(() => { navRef.current({ search: prev => ({ ...prev, page: pagination.page }) }); }, [pagination.page]); + useEffect(() => { - if (pagination.totalPages === oldTotal) + if (pagination.totalPages === oldTotal.current) return; + paginationRef.current.reset(); - setOldTotal(pagination.totalPages); + oldTotal.current = pagination.totalPages; }, [pagination.totalPages]); + return (
+
); diff --git a/apps/oneclient/frontend/src/routes/app/cluster/browser/package.tsx b/apps/oneclient/frontend/src/routes/app/cluster/browser/package.tsx index c5826ee1..f325a871 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/browser/package.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/browser/package.tsx @@ -1,17 +1,14 @@ import type { ClusterModel, ManagedPackage, ManagedUser, ManagedVersion, PackageDonationUrl, Provider } from '@/bindings.gen'; -import type { HTMLProps } from 'react'; +import { LoaderSuspense } from '@/components'; +import { ExternalLink } from '@/components/ExternalLink'; +import { Markdown } from '@/components/Markdown'; import { bindings } from '@/main'; -import { abbreviateNumber, formatAsRelative, upperFirst, useCommand, useCommandMut } from '@onelauncher/common'; -import { Button, Show, Tooltip } from '@onelauncher/common/components'; -import { createFileRoute, Link, redirect } from '@tanstack/react-router'; -import { openUrl } from '@tauri-apps/plugin-opener'; -import { CalendarIcon, ClockRewindIcon, Download01Icon, File02Icon, LinkExternal01Icon } from '@untitled-theme/icons-react'; +import { abbreviateNumber, formatAsRelative, upperFirst, useCommand, useCommandMut, useCommandSuspense } from '@onelauncher/common'; +import { Button, Tooltip } from '@onelauncher/common/components'; +import { createFileRoute, redirect } from '@tanstack/react-router'; +import { CalendarIcon, ClockRewindIcon, Download01Icon, File02Icon } from '@untitled-theme/icons-react'; import { useMemo } from 'react'; import { Cell, Column, Row, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs } from 'react-aria-components'; -import Markdown from 'react-markdown'; -import rehypeRaw from 'rehype-raw'; -import remarkGfm from 'remark-gfm'; -import { twMerge } from 'tailwind-merge'; export interface PackageRouteSearchParams { packageId: string; @@ -22,22 +19,20 @@ export const Route = createFileRoute('/app/cluster/browser/package')({ validateSearch: (search): PackageRouteSearchParams => ({ packageId: search.packageId as string, }), - async beforeLoad({ context, search }) { + async beforeLoad({ search }) { if (!search.packageId) throw redirect({ to: '/app/cluster', from: '/app/cluster/browser/package', search }); - const [managedPackage, packageVersions] = await Promise.all([ - bindings.core.getPackage(search.provider, search.packageId), - bindings.core.getPackageVersions(search.provider, search.packageId, context.cluster.mc_version, context.cluster.mc_loader, 0, 64), - ]); - const packageBody = await bindings.core.getPackageBody(search.provider, managedPackage.body); - return { managedPackage, packageBody, packageVersions }; + + const managedPackage = await bindings.core.getPackage(search.provider, search.packageId); + + return { managedPackage }; }, }); function RouteComponent() { - const { managedPackage, packageBody } = Route.useRouteContext(); + const { managedPackage } = Route.useRouteContext(); + return ( - //
@@ -45,24 +40,21 @@ function RouteComponent() { About Versions - Gallery + {/* Gallery */} - -
- , - }} - rehypePlugins={[rehypeRaw]} - remarkPlugins={[remarkGfm]} - > - {packageBody} - -
+ + + + + - - + + + + + + {/* {managedPackage.gallery.map(item => ( @@ -81,26 +73,22 @@ function RouteComponent() { -
- //
); } -function CustomA({ href, children, includeIcon, className, ...rest }: { href: string; children: any; includeIcon?: boolean } & HTMLProps) { +function BodyPanel() { + const { provider } = Route.useSearch(); + const { managedPackage } = Route.useRouteContext(); + + const { data: body } = useCommandSuspense(['getPackageBody', provider, managedPackage.body], () => bindings.core.getPackageBody(provider, managedPackage.body)); + return ( - { - e.preventDefault(); - openUrl(href); - }} - {...rest} - > - {children} - {includeIcon && } - +
+
+ +
+
); } @@ -118,7 +106,7 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) { const createdAt = useMemo(() => pkg.created ? new Date(pkg.created) : null, [pkg.created]); const updatedAt = useMemo(() => pkg.updated ? new Date(pkg.updated) : null, [pkg.updated]); - const authors = useCommand(['getUsersFromAuthor', pkg.author], () => bindings.core.getUsersFromAuthor(provider as Provider, pkg.author)); + const authors = useCommand(['getUsersFromAuthor', pkg.author], () => bindings.core.getUsersFromAuthor(provider, pkg.author)); return (
@@ -142,12 +130,12 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {

{pkg.short_desc}

- + {pkg.provider !== 'SkyClient' && (
{abbreviateNumber(pkg.downloads)}
-
+ )}
@@ -157,15 +145,15 @@ function BrowserSidebar({ package: pkg }: { package: ManagedPackage }) {

Links

- + {provider} {' '} Page - + {(Object.entries(pkg.links) as Array<[keyof typeof pkg.links, typeof pkg.links[keyof typeof pkg.links]]>).filter(a => a[1]).map(link => typeof link[1] == 'string' ? ( - ) : (link[1] as Array).map(donationLink => ( -

Details

- + {pkg.license !== null && (
License - - {pkg.license?.name || pkg.license?.id || 'Unknown'} - + + {pkg.license.name || pkg.license.id || 'Unknown'} +
-
+ )} - - + {createdAt !== null && ( +
Created - {formatAsRelative(createdAt!.getTime(), 'en', 'long')} + {formatAsRelative(createdAt.getTime(), 'en', 'long')}
-
+ )} - - + {updatedAt !== null && ( +
Last Updated - {formatAsRelative(updatedAt!.getTime(), 'en', 'long')} + {formatAsRelative(updatedAt.getTime(), 'en', 'long')}
-
+ )}
); } +function usePackageVersions() { + const { provider, packageId } = Route.useSearch(); + const { cluster } = Route.useRouteContext(); + const { data: versions } = useCommandSuspense( + ['getPackageVersions', provider, packageId, cluster.mc_version, cluster.mc_loader], + () => bindings.core.getPackageVersions(provider, packageId, cluster.mc_version, cluster.mc_loader, 0, 64), + ); + + return versions; +} + function InstallButton() { - const { packageVersions, managedPackage } = Route.useRouteContext(); - const { provider, clusterId } = Route.useSearch(); - const download = useCommandMut(() => bindings.core.downloadPackage(provider, managedPackage.id, packageVersions.items[0].version_id, clusterId, true)); + const { cluster, managedPackage } = Route.useRouteContext(); + const packageVersions = usePackageVersions(); + + const { provider } = Route.useSearch(); + const download = useCommandMut(() => bindings.core.downloadPackage(provider, managedPackage.id, packageVersions.items[0].version_id, cluster.id, true)); return ( ); } function Author({ author }: { author: ManagedUser }) { - // TODO: onclick and fallback avatar url return ( - { - if (author.url) - openUrl(author.url); - }} + {`${author.username}'s -
+
{author.username} - + {author.is_organization_user && ( Organization - + )} - + {author.role !== null && ( {author.role} - + )}
- -
+ ); } @@ -306,7 +310,7 @@ function Versions() { // offset, // }); - const { packageVersions: versions } = Route.useRouteContext(); + const versions = usePackageVersions(); // const pagination = usePagination({ // itemsCount: versions?.total as unknown as number, @@ -324,7 +328,7 @@ function Versions() {
{/* */}
- +
Name @@ -334,8 +338,8 @@ function Versions() { Downloads - - {versions?.items.map(item => + + {versions.items.map(item => )}
diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 825a0d18..7cfcf3f4 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -176,6 +176,9 @@ pub trait TauriLauncherApi { async fn fetch_player_profile( uuid: String, ) -> LauncherResult; + + // MARK: API: Other + async fn open(input: String) -> LauncherResult<()>; } #[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)] @@ -617,4 +620,10 @@ impl TauriLauncherApi for TauriLauncherApiImpl { ) -> LauncherResult { crate::utils::minecraft::fetch_player_profile(&uuid).await } + + // MARK: Impl: Other + async fn open(self, input: String) -> LauncherResult<()> { + opener::open(input)?; + Ok(()) + } }