diff --git a/Cargo.lock b/Cargo.lock index 34bbd097..cb7859f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2512,6 +2512,25 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -3482,6 +3501,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", @@ -3512,6 +3532,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "open" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3673,6 +3705,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -6010,6 +6048,28 @@ dependencies = [ "uuid 1.16.0", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8983f50326d34437142a6d560b5c3426e91324297519b6eeb32ed0a1d1e0f2" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation 0.3.1", + "open", + "schemars", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "url", + "windows 0.61.1", + "zbus", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.2.3" diff --git a/Cargo.toml b/Cargo.toml index 70845882..90fed91f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ tauri-plugin-dialog = { version = "=2.2.1" } tauri-plugin-single-instance = { version = "=2.2.3" } tauri-plugin-updater = { version = "=2.7.1" } tauri-plugin-window-state = { version = "=2.2.2" } +tauri-plugin-opener = { version = "=2.3.0" } # code gen strum = { version = "=0.27.1", features = [ "derive" ] } diff --git a/apps/onelauncher/desktop/Cargo.toml b/apps/onelauncher/desktop/Cargo.toml index 1d613caa..188155e3 100644 --- a/apps/onelauncher/desktop/Cargo.toml +++ b/apps/onelauncher/desktop/Cargo.toml @@ -37,6 +37,7 @@ tauri-plugin-updater = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-dialog = { workspace = true } tauri-plugin-deep-link = { workspace = true } +tauri-plugin-opener = { workspace = true } # code gen taurpc = { workspace = true } diff --git a/apps/onelauncher/desktop/capabilities/default.json b/apps/onelauncher/desktop/capabilities/default.json index 40d2165e..b4bfbae1 100644 --- a/apps/onelauncher/desktop/capabilities/default.json +++ b/apps/onelauncher/desktop/capabilities/default.json @@ -2,7 +2,9 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "capabilities for the main window", - "windows": ["*"], + "windows": [ + "*" + ], "permissions": [ "core:app:default", "core:webview:default", @@ -12,7 +14,6 @@ "core:resources:default", "core:tray:default", "core:window:default", - "core:window:allow-close", "core:window:allow-create", "core:window:allow-destroy", @@ -24,13 +25,23 @@ "core:window:allow-set-decorations", "core:window:allow-set-title-bar-style", "core:webview:allow-internal-toggle-devtools", - "core:event:default", "clipboard-manager:allow-write-text", "dialog:allow-open", "dialog:allow-save", "dialog:allow-confirm", "deep-link:default", - "updater:default" + "updater:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { + "path": "$DATA/OneLauncher/**" + }, + { + "path": "$DATA/OneLauncher" + } + ] + } ] } diff --git a/apps/onelauncher/desktop/src/api/commands.rs b/apps/onelauncher/desktop/src/api/commands.rs index 7735c9af..58d7ddb5 100644 --- a/apps/onelauncher/desktop/src/api/commands.rs +++ b/apps/onelauncher/desktop/src/api/commands.rs @@ -6,6 +6,8 @@ pub trait OneLauncherApi { async fn return_error() -> LauncherResult<()>; async fn open_dev_tools(webview_window: tauri::WebviewWindow); + + async fn set_window_style(webview_window: tauri::WebviewWindow, decorations: bool); } #[taurpc::ipc_type] @@ -24,4 +26,7 @@ impl OneLauncherApi for OneLauncherApiImpl { webview_window.open_devtools(); } + async fn set_window_style(self, webview_window: tauri::WebviewWindow, decorations: bool) { + webview_window.set_decorations(decorations).ok(); + } } diff --git a/apps/onelauncher/desktop/src/lib.rs b/apps/onelauncher/desktop/src/lib.rs index 7bc4dad5..5aaa252c 100644 --- a/apps/onelauncher/desktop/src/lib.rs +++ b/apps/onelauncher/desktop/src/lib.rs @@ -54,7 +54,8 @@ async fn initialize_tauri(builder: tauri::Builder) -> LauncherResult .export_config( specta_typescript::Typescript::default() .bigint(specta_typescript::BigIntExportBehavior::BigInt) - .formatter(ext::specta::formatter), + .formatter(ext::specta::formatter) + .header("// @ts-nocheck \n"), ) .merge(api::commands::OneLauncherApiImpl.into_handler()) .merge(onelauncher_core::api::tauri::TauriLauncherApiImpl.into_handler()) @@ -70,9 +71,16 @@ async fn initialize_tauri(builder: tauri::Builder) -> LauncherResult .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_opener::init()) .menu(tauri::menu::Menu::new) .invoke_handler(router.into_handler()) .setup(move |app| { + let launcher_path = app.path().data_dir().ok().expect("ok").join("OneLauncher"); + + app.asset_protocol_scope() + .allow_directory(&launcher_path, true) + .unwrap(); + setup_window(app.handle()).expect("failed to setup main window"); Ok(()) }); @@ -114,11 +122,16 @@ fn setup_window(handle: &tauri::AppHandle) -> Result<(), Box bindings.core.getDefaultUser(false)); + const { list } = useNotifications(); + const notificationCount = Object.keys(list).length; return (
@@ -36,36 +39,31 @@ function Navbar() { {/* Notification Manager Button */}
- - - - - Hello World - - - - - {/* el.classList.add('right-0')} - setVisible={setNotificationMenuOpen} - visible={notificationMenuOpen} - /> */} + + + +
- + - + - - + +
); diff --git a/apps/onelauncher/frontend/src/components/SettingsRow.tsx b/apps/onelauncher/frontend/src/components/SettingsRow.tsx index ce336cae..4bfe7435 100644 --- a/apps/onelauncher/frontend/src/components/SettingsRow.tsx +++ b/apps/onelauncher/frontend/src/components/SettingsRow.tsx @@ -21,7 +21,7 @@ function SettingsRow({ disabled, description, icon, title, children }: SettingsR
-

{title}

+

{title}

{description}

diff --git a/apps/onelauncher/frontend/src/components/WindowFrame.tsx b/apps/onelauncher/frontend/src/components/WindowFrame.tsx index d7c90fdf..0c65c922 100644 --- a/apps/onelauncher/frontend/src/components/WindowFrame.tsx +++ b/apps/onelauncher/frontend/src/components/WindowFrame.tsx @@ -1,6 +1,7 @@ import type { ComponentType, JSX } from 'react'; import type { PressEvent } from 'react-aria-components'; -import { Button } from '@onelauncher/common/components'; +import useSettings from '@/hooks/useSettings'; +import { Button, Show } from '@onelauncher/common/components'; import { Window } from '@tauri-apps/api/window'; import { ChevronLeftIcon, Maximize02Icon, Minimize01Icon, MinusIcon, XCloseIcon } from '@untitled-theme/icons-react'; import { useEffect, useState } from 'react'; @@ -8,6 +9,7 @@ import { twMerge } from 'tailwind-merge'; function WindowFrame() { const [isMaximized, setIsMaximized] = useState(false); + const { settings } = useSettings(); const back = () => history.back(); const minimize = () => Window.getCurrent().minimize(); @@ -27,17 +29,19 @@ function WindowFrame() { }, []); return ( -
-
- -
+ +
+
+ +
-
- - - +
+ + + +
-
+
); } diff --git a/apps/onelauncher/frontend/src/components/content/ProviderIcon.tsx b/apps/onelauncher/frontend/src/components/content/ProviderIcon.tsx new file mode 100644 index 00000000..d040c07f --- /dev/null +++ b/apps/onelauncher/frontend/src/components/content/ProviderIcon.tsx @@ -0,0 +1,38 @@ +import type { ImgHTMLAttributes, RefAttributes } from 'react'; +import CurseforgeIcon from '@/assets/logos/curseforge.svg'; +import VanillaImage from '@/assets/logos/minecraft.png'; +import ModrinthIcon from '@/assets/logos/modrinth.svg'; +import SkyClientImage from '@/assets/logos/skyclient.png'; + +export function getProviderLogoSrc(provider: string): string { + const providerName = provider.toLowerCase(); + + const mapping: Record = { + modrinth: ModrinthIcon, + curseforge: CurseforgeIcon, + skyclient: SkyClientImage, + }; + + return mapping[providerName]; +} + +type ProviderIconProp = ImgHTMLAttributes & RefAttributes & { + provider: string | undefined; +}; + +function ProviderIcon({ + provider, + src: _src, + alt: _alt, + ...rest +}: ProviderIconProp) { + return ( + {`${provider}'s + ); +} + +export default ProviderIcon; diff --git a/apps/onelauncher/frontend/src/components/content/WorldIcon.tsx b/apps/onelauncher/frontend/src/components/content/WorldIcon.tsx new file mode 100644 index 00000000..19874292 --- /dev/null +++ b/apps/onelauncher/frontend/src/components/content/WorldIcon.tsx @@ -0,0 +1,37 @@ +import DefaultWorld from '@/assets/images/default_world.png'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { dataDir, join } from '@tauri-apps/api/path'; +import { useEffect, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +export interface WorldIconProps { + world_name: string; + cluster_name: string; + className?: string; +} + +export default function WorldIcon(props: WorldIconProps) { + const { world_name, cluster_name, className } = props; + const [imagePath, setImagePath] = useState(''); + + useEffect(() => { + async function loadImagePath() { + const iconPath = await join(await dataDir(), 'OneLauncher', 'clusters', cluster_name, 'saves', world_name, 'icon.png'); + setImagePath(convertFileSrc(iconPath)); + } + + loadImagePath().catch(console.error); + }, [cluster_name, world_name]); + + return ( + {`${world_name} { + e.currentTarget.src = DefaultWorld; + }} + src={imagePath} + /> + ); +} diff --git a/apps/onelauncher/frontend/src/components/launcher/LoaderIcon.tsx b/apps/onelauncher/frontend/src/components/launcher/LoaderIcon.tsx index 50b2c1b3..b69ca3b9 100644 --- a/apps/onelauncher/frontend/src/components/launcher/LoaderIcon.tsx +++ b/apps/onelauncher/frontend/src/components/launcher/LoaderIcon.tsx @@ -1,4 +1,4 @@ -import type { GameLoader, Model } from '@/bindings.gen'; +import type { ClusterModel, GameLoader } from '@/bindings.gen'; import type { ImgHTMLAttributes, RefAttributes } from 'react'; import FabricImage from '@/assets/logos/loaders/fabric.png'; import ForgeImage from '@/assets/logos/loaders/forge.png'; @@ -6,7 +6,7 @@ import NeoForgeImage from '@/assets/logos/loaders/neoforge.png'; import QuiltImage from '@/assets/logos/loaders/quilt.png'; import VanillaImage from '@/assets/logos/minecraft.png'; -export function getLoaderLogoSrc(loader: Model | GameLoader): string { +export function getLoaderLogoSrc(loader: ClusterModel | GameLoader): string { const loaderName = (typeof loader === 'string' ? loader : loader.mc_loader).toLowerCase() as GameLoader; const mapping: Record = { diff --git a/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx b/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx index 7eb4cb06..8d0a8e8e 100644 --- a/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx +++ b/apps/onelauncher/frontend/src/components/launcher/cluster/ClusterCreation.tsx @@ -1,13 +1,12 @@ -import type { IngressPayload, Version, VersionType } from '@/bindings.gen'; +import type { GameLoader, Version, VersionType } from '@/bindings.gen'; import type { JSX, RefAttributes } from 'react'; import DefaultBanner from '@/assets/images/default_banner.png'; -import DefaultClusterBanner from '@/assets/images/default_instance_cover.jpg'; import LauncherIcon from '@/components/content/LauncherIcon'; import Modal from '@/components/overlay/Modal'; import ScrollableContainer from '@/components/ScrollableContainer'; import { bindings } from '@/main'; -import { LAUNCHER_IMPORT_TYPES } from '@/utils'; -import { useCommand } from '@onelauncher/common'; +import { LAUNCHER_IMPORT_TYPES, LOADERS, upperFirst } from '@/utils'; +import { useCommand, useCommandMut } from '@onelauncher/common'; import { Button, Dropdown, SelectList, TextField } from '@onelauncher/common/components'; import { useQueryClient } from '@tanstack/react-query'; import { ArrowRightIcon, PlusIcon, SearchMdIcon, Server01Icon, TextInputIcon, User03Icon } from '@untitled-theme/icons-react'; @@ -26,37 +25,43 @@ const STEPS = [ }, ] as const; +interface ClusterFormData { + loader: GameLoader; + clusterName: string; + clusterVersion: string; + clusterProvider: string; +} + export function NewClusterCreate() { + const queryClient = useQueryClient(); const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [formData, setFormData] = useState({ - loader: '', + const [formData, setFormData] = useState({ + loader: 'vanilla', clusterName: '', clusterVersion: '', + clusterProvider: '', }); - const create = useCommand('createCluster', () => bindings.core.createCluster({ - icon: DefaultClusterBanner, - mc_loader: 'vanilla', + const create = useCommandMut(() => bindings.core.createCluster({ + icon: null, + mc_loader: formData.loader, mc_loader_version: null, mc_version: formData.clusterVersion, name: formData.clusterName, }), { - enabled: false, - subscribed: false, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['getClusters'] }); + }, }); - const queryClient = useQueryClient(); - function handleCreate() { - if (!isLastInputStep) + if (!isLastInputStep && !formData.clusterName) return; - create.refetch(); + create.mutate(); if (create.isError) console.error(create.error.message); - - queryClient.invalidateQueries({ queryKey: ['getClusters'], exact: true }); } const handleNext = useCallback(async () => { @@ -78,10 +83,7 @@ export function NewClusterCreate() { switch (currentStepConfig.id) { case 'loader': { stepContent = ( - setFormData(prev => ({ ...prev, loader }))} - selectedLoader={formData.loader} - /> + setFormData(prev => ({ ...prev, clusterProvider: provider }))} selectedProvider={formData.clusterProvider} /> ); break; } @@ -89,8 +91,10 @@ export function NewClusterCreate() { case 'info': { stepContent = ( setFormData(prev => ({ ...prev, loader }))} onClusterNameChange={name => setFormData(prev => ({ ...prev, clusterName: name }))} onClusterVersionChange={version => setFormData(prev => ({ ...prev, clusterVersion: version }))} @@ -105,7 +109,7 @@ export function NewClusterCreate() { const nextButtonText = isLastInputStep ? 'Create' : 'Next'; let isNextDisabled = false; - if (currentStepConfig.id === 'loader' && !formData.loader) + if (currentStepConfig.id === 'loader' && !formData.clusterProvider) isNextDisabled = true; else if ( currentStepConfig.id === 'info' @@ -174,19 +178,19 @@ export function NewClusterCreate() { } interface ClusterLoaderProps { - selectedLoader: string; - onSelectLoader: (loader: string) => void; + selectedProvider: string; + onSelectProvider: (provider: string) => void; } -function ClusterLoader({ selectedLoader, onSelectLoader }: ClusterLoaderProps) { +function ClusterLoader({ selectedProvider, onSelectProvider }: ClusterLoaderProps) { return ( <>
} name="New" - selected={selectedLoader === 'new'} - setSelected={() => onSelectLoader('new')} + selected={selectedProvider === 'new'} + setSelected={() => onSelectProvider('new')} /> {LAUNCHER_IMPORT_TYPES.map((data, i) => ( @@ -194,8 +198,8 @@ function ClusterLoader({ selectedLoader, onSelectLoader }: ClusterLoaderProps) { icon={} key={i} name={data} - selected={selectedLoader === data} - setSelected={() => onSelectLoader(data)} + selected={selectedProvider === data} + setSelected={() => onSelectProvider(data)} /> ))}
@@ -231,6 +235,8 @@ interface ClusterInformationProps { onClusterNameChange: (name: string) => void; clusterVersion: string; onClusterVersionChange: (version: string) => void; + clusterLoader: GameLoader; + onClusterLoaderChange: (loader: GameLoader) => void; } function ClusterInformation({ @@ -238,6 +244,8 @@ function ClusterInformation({ onClusterNameChange, clusterVersion, onClusterVersionChange, + clusterLoader, + onClusterLoaderChange, }: ClusterInformationProps) { const versions = useCommand('getGameVersions', bindings.core.getGameVersions); @@ -249,6 +257,7 @@ function ClusterInformation({ iconLeft={} onChange={e => onClusterNameChange(e.target.value)} placeholder="Name" + required value={clusterName} /> @@ -258,35 +267,16 @@ function ClusterInformation({
diff --git a/apps/onelauncher/frontend/src/components/overlay/Modal.tsx b/apps/onelauncher/frontend/src/components/overlay/Modal.tsx index cc32aa79..373490c4 100644 --- a/apps/onelauncher/frontend/src/components/overlay/Modal.tsx +++ b/apps/onelauncher/frontend/src/components/overlay/Modal.tsx @@ -1,17 +1,20 @@ -import type { ClassNameString } from '@onelauncher/common'; import type { DialogProps } from 'react-aria-components'; import { Dialog as AriaDialog, + Modal as AriaModal, DialogTrigger, - Modal as ModalOverlay, + ModalOverlay, } from 'react-aria-components'; -interface ModalProps extends DialogProps, ClassNameString { } +interface ModalProps extends DialogProps { } function Modal({ className, ...props }: ModalProps) { return ( - + + + + ); } diff --git a/apps/onelauncher/frontend/src/components/overlay/NotificationToasts.tsx b/apps/onelauncher/frontend/src/components/overlay/NotificationToasts.tsx new file mode 100644 index 00000000..ca4d6e16 --- /dev/null +++ b/apps/onelauncher/frontend/src/components/overlay/NotificationToasts.tsx @@ -0,0 +1,127 @@ +import type { NotificationData } from '@/hooks/useNotification'; +import useNotifications from '@/hooks/useNotification'; +import { Button, Show } from '@onelauncher/common/components'; +import { InfoCircleIcon, XIcon } from '@untitled-theme/icons-react'; +import { useCallback, useEffect, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface ToastNotification extends NotificationData { + id: string; + timestamp: number; +} + +interface NotificationToastProps { + notification: ToastNotification; + onRemove: (id: string) => void; +} + +const fractionEnded = (fraction: number | undefined) => fraction !== undefined && fraction >= 0.97; + +export function NotificationToast({ notification, onRemove }: NotificationToastProps) { + const [isVisible, setIsVisible] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + + const handleRemove = useCallback(() => { + setIsRemoving(true); + setTimeout(() => { + onRemove(notification.id); + }, 300); + }, [notification.id, onRemove]); + + useEffect(() => { + // Trigger entrance animation + const timer = setTimeout(() => setIsVisible(true), 50); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + // Auto-remove after 5 seconds if no progress indicator + if (notification.fraction === undefined) { + const timer = setTimeout(handleRemove, 5000); + return () => clearTimeout(timer); + } + }, [notification.fraction, handleRemove]); + + return ( +
+
+ + +
+ {notification.title} + {notification.message} +
+ +
+ + +
+
+
+ +
+ ); +} + +function NotificationToasts() { + const { list } = useNotifications(); + const [toasts, setToasts] = useState>([]); + + useEffect(() => { + const currentToasts = Object.entries(list).map(([id, data]) => ({ + id, + ...data, + timestamp: Date.now(), + })); + + setToasts((prev) => { + const existingIds = new Set(prev.map(t => t.id)); + const newToasts = currentToasts.filter(toast => !existingIds.has(toast.id)); + + const updatedToasts = prev.map((toast) => { + const updated = currentToasts.find(t => t.id === toast.id); + return updated ? { ...toast, ...updated } : toast; + }); + + const currentIds = new Set(currentToasts.map(t => t.id)); + const filteredToasts = updatedToasts.filter(toast => currentIds.has(toast.id)); + + return [...filteredToasts, ...newToasts]; + }); + }, [list]); + + const removeToast = (id: string) => { + setToasts(prev => prev.filter(toast => toast.id !== id)); + }; + + if (toasts.length === 0) + return null; + + return ( +
+
+ {toasts.map(toast => ( + + ))} +
+
+ ); +} + +export default NotificationToasts; diff --git a/apps/onelauncher/frontend/src/components/overlay/popups/AccountPopup.tsx b/apps/onelauncher/frontend/src/components/overlay/popups/AccountPopup.tsx index 65fd6c36..e6aa0c71 100644 --- a/apps/onelauncher/frontend/src/components/overlay/popups/AccountPopup.tsx +++ b/apps/onelauncher/frontend/src/components/overlay/popups/AccountPopup.tsx @@ -2,58 +2,61 @@ import type { MinecraftCredentials } from '@/bindings.gen'; import PlayerHead from '@/components/content/PlayerHead'; import { bindings } from '@/main'; import { useCommand } from '@onelauncher/common'; -import { Button, Menu } from '@onelauncher/common/components'; +import { Button } from '@onelauncher/common/components'; +import { Link } from '@tanstack/react-router'; import { PlusIcon, Settings01Icon } from '@untitled-theme/icons-react'; -import { Separator } from 'react-aria-components'; import { twMerge } from 'tailwind-merge'; function AccountPopup() { const users = useCommand('getUsers', bindings.core.getUsers); const defaultUser = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false)); + const addAccount = useCommand('openMsaLogin', bindings.core.openMsaLogin, { + enabled: false, + subscribed: false, + }); + const setDefaultUser = (user: MinecraftCredentials) => { - bindings.commands.setDefaultUser(user.id); + bindings.core.setDefaultUser(user.id); }; return ( - +
{defaultUser.data && ( - +
{}} + onClick={() => { }} user={defaultUser.data} /> - +
)} - {users.data?.filter(user => user.id === defaultUser.data?.id).map(user => ( - + {users.data?.filter(user => user.id !== defaultUser.data?.id).map(user => ( +
setDefaultUser(user)} user={user} /> - +
))} - {(users.data?.length ?? 0) > 0 && ( - - )} - - -
-
- + + +
-
-
+
+ ); } diff --git a/apps/onelauncher/frontend/src/components/overlay/popups/NotificationPopup.tsx b/apps/onelauncher/frontend/src/components/overlay/popups/NotificationPopup.tsx new file mode 100644 index 00000000..35c5927b --- /dev/null +++ b/apps/onelauncher/frontend/src/components/overlay/popups/NotificationPopup.tsx @@ -0,0 +1,73 @@ +import useNotifications from '@/hooks/useNotification'; +import { Button } from '@onelauncher/common/components'; +import { InfoCircleIcon, Trash01Icon } from '@untitled-theme/icons-react'; + +function NotificationPopup() { + const { list, clear } = useNotifications(); + const notificationEntries = Object.entries(list); + + if (notificationEntries.length === 0) + return ( +
+

No notifications

+
+ ); + + return ( +
+
+
+

Notifications

+
+ +
+ {notificationEntries.map(([id, data]) => ( +
+
+ +
+ +
+

+ {data.title} +

+

+ {data.message} +

+
+ +
+
+
+
+ ))} +
+ +
+ + {notificationEntries.length > 0 && ( +
+ + {/* */} +
+ )} +
+
+ ); +} + +export default NotificationPopup; diff --git a/apps/onelauncher/frontend/src/hooks/useBrowser.tsx b/apps/onelauncher/frontend/src/hooks/useBrowser.tsx new file mode 100644 index 00000000..51be9ff1 --- /dev/null +++ b/apps/onelauncher/frontend/src/hooks/useBrowser.tsx @@ -0,0 +1,57 @@ +import type { ClusterModel, Provider, SearchQuery } from '@/bindings.gen'; +import { bindings } from '@/main'; +import { PROVIDERS } from '@/utils'; +import { useCommand } from '@onelauncher/common'; +import { createContext, useContext, useMemo, useState } from 'react'; + +export interface BrowserControllerType { + cluster: ClusterModel | undefined; + setCluster: (cluster: ClusterModel | undefined) => void; + provider: Provider; + setProvider: (provider: Provider) => void; + query: SearchQuery; + setQuery: (query: SearchQuery) => void; + search: () => void; +} + +const BrowserContext = createContext(null); + +export function useBrowserContext() { + const context = useContext(BrowserContext); + if (!context) + throw new Error('useBrowserContext must be used within a BrowserProvider'); + return context; +} + +export function BrowserProvider(props: any) { + const { children } = props; + const [cluster, setCluster] = useState(undefined); + const [provider, setProvider] = useState(PROVIDERS[0]); + const [query, setQuery] = useState({ + filters: null, + query: null, + limit: 18 as unknown as bigint, + offset: null, + sort: null, + }); + + const context = useMemo(() => ({ + cluster, + setCluster, + provider, + setProvider, + query, + setQuery, + search: () => {}, + }), [cluster, provider, query]); + + return ( + + {children} + + ); +} + +export function useBrowserSearch(provider: Provider, query: SearchQuery) { + return useCommand('searchPackages', () => bindings.core.searchPackages(provider, query)); +} diff --git a/apps/onelauncher/frontend/src/hooks/useCluster.ts b/apps/onelauncher/frontend/src/hooks/useCluster.ts index eb8e44e7..b9db6006 100644 --- a/apps/onelauncher/frontend/src/hooks/useCluster.ts +++ b/apps/onelauncher/frontend/src/hooks/useCluster.ts @@ -1,17 +1,17 @@ -import type { Model } from '@/bindings.gen'; +import type { ClusterModel } from '@/bindings.gen'; import { bindings } from '@/main'; import { useCommand } from '@onelauncher/common'; import { useEffect, useState } from 'react'; -function useRecentCluster() { +export function useRecentCluster() { const result = useCommand('getClusters', bindings.core.getClusters); - const [cluster, setCluster] = useState(); + const [cluster, setCluster] = useState(); useEffect(() => { if (!result.data) return; - let mostRecentCluster: Model | undefined; + let mostRecentCluster: ClusterModel | undefined; for (const c of result.data) { if (!mostRecentCluster) { @@ -40,4 +40,15 @@ function useRecentCluster() { return cluster; } -export default useRecentCluster; +export function useClusters() { + const result = useCommand('getClusters', bindings.core.getClusters); + const [clusters, setClusters] = useState | undefined>(); + + useEffect(() => { + if (!result.data) + return; + setClusters(result.data); + }, [result.data]); + + return clusters; +} diff --git a/apps/onelauncher/frontend/src/hooks/useNotification.tsx b/apps/onelauncher/frontend/src/hooks/useNotification.tsx new file mode 100644 index 00000000..3d4f4fe8 --- /dev/null +++ b/apps/onelauncher/frontend/src/hooks/useNotification.tsx @@ -0,0 +1,123 @@ +import type { ReactNode } from 'react'; +import { bindings } from '@/main'; +import { randomString } from '@/utils/index'; +import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; + +export interface NotificationData { + title: string; + message: string; + fraction?: number | undefined; +} + +type Notifications = Record; + +export interface HookReturn { + list: Notifications; + set: (id: string, data: NotificationData) => void; + create: (data: NotificationData) => void; + remove: (id: string) => void; + clear: () => void; +} + +interface NotificationContextValue { + notifications: Notifications; + setNotifications: React.Dispatch>; +} + +const defaultContextValue: NotificationContextValue = { + notifications: {}, + setNotifications: () => { }, +}; + +const NotificationContext = createContext(defaultContextValue); + +interface NotificationProviderProps { + children: ReactNode; +} + +export const NotificationProvider = memo(({ children }: NotificationProviderProps) => { + const [notifications, setNotifications] = useState({}); + + useEffect(() => { + let cleanup: (() => void) | undefined; + + bindings.events.ingress.on((e) => { + if (typeof e.ingress_type === 'object') { + if ('PrepareCluster' in e.ingress_type) { + const { cluster_name } = e.ingress_type.PrepareCluster; + setNotifications(prev => ({ + ...prev, + [e.id]: { + title: `Preparing ${cluster_name}`, + message: e.message, + fraction: e.percent ?? undefined, + }, + })); + } + } + else { + setNotifications(prev => ({ + ...prev, + [e.id]: { + title: `${e.ingress_type}`, + message: e.message, + fraction: e.percent ?? undefined, + }, + })); + } + }).then((u) => { + cleanup = u; + }).catch(console.error); + + return () => { + cleanup?.(); + }; + }, []); + + const contextValue = useMemo(() => ({ + notifications, + setNotifications, + }), [notifications, setNotifications]); + + return ( + + {children} + + ); +}); + +function useNotifications(): HookReturn { + const context = useContext(NotificationContext); + const { notifications, setNotifications } = context; + + const isDefaultContext = setNotifications === defaultContextValue.setNotifications; + + if (isDefaultContext) { + if (import.meta.env.DEV) { + console.warn('useNotifications: Using default context (likely due to HMR or missing provider)'); + return { + list: {}, + set: () => { }, + create: () => { }, + remove: () => { }, + clear: () => { }, + }; + } + throw new Error('useNotifications must be used within a NotificationProvider'); + } + + const ctx: HookReturn = { + list: notifications, + set: (id, data) => setNotifications(prev => ({ ...prev, [id]: data })), + create: data => ctx.set(randomString(6), data), + remove: id => setNotifications((prev) => { + const { [id]: removed, ...rest } = prev; + return rest; + }), + clear: () => setNotifications({}), + }; + + return ctx; +} + +export default useNotifications; diff --git a/apps/onelauncher/frontend/src/hooks/usePopState.ts b/apps/onelauncher/frontend/src/hooks/usePopState.ts new file mode 100644 index 00000000..5714b47c --- /dev/null +++ b/apps/onelauncher/frontend/src/hooks/usePopState.ts @@ -0,0 +1,20 @@ +import { useLocation } from '@tanstack/react-router'; +import { useEffect, useRef } from 'react'; + +export default function usePopState(onRouteChange: void | any) { + const location = useLocation(); + const prevLocationRef = useRef(location); + + useEffect(() => { + const prevLocation = prevLocationRef.current; + + // Only run if the pathname actually changed + if (prevLocation.pathname !== location.pathname) + onRouteChange?.({ + from: prevLocation, + to: location, + }); + + prevLocationRef.current = location; + }, [location, onRouteChange]); +} diff --git a/apps/onelauncher/frontend/src/hooks/useSettings.tsx b/apps/onelauncher/frontend/src/hooks/useSettings.tsx new file mode 100644 index 00000000..cf820eee --- /dev/null +++ b/apps/onelauncher/frontend/src/hooks/useSettings.tsx @@ -0,0 +1,133 @@ +/* eslint-disable react-hooks/rules-of-hooks -- stfu */ +/* eslint-disable ts/naming-convention -- please shut the fuck up i need to see if its working or not */ +import type { Settings } from '@/bindings.gen'; +import { bindings } from '@/main'; +import { useCommand } from '@onelauncher/common'; +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +interface SettingsControllerType { + settings: Settings | undefined; + saveChangedSettings: () => void; + settingsToSave: Partial | null; + setSettingsToSave: (settings: Partial | null) => void; + settingsChanged: boolean; + setSettingsChanged: (changed: boolean) => void; + createSetting: (name: K, value: V) => [V, (value: V) => void]; +} + +const SettingsContext = createContext(null); + +interface SettingsProviderProps { + children: React.ReactNode; +} + +export function SettingsProvider({ children }: SettingsProviderProps) { + const { data: settings } = useCommand('readSettings', bindings.core.readSettings); + const [settingsChanged, setSettingsChanged] = useState(false); + const [settingsToSave, setSettingsToSave] = useState | null>(null); + + const filteredSettingsToSave = settingsToSave + ? Object.fromEntries( + Object.entries(settingsToSave), + ) as Partial + : {}; + + const patchedSettings: Settings = { + ...settings, + ...filteredSettingsToSave, + } as Settings; + + const writeQuery = useCommand('writeSettings', () => bindings.core.writeSettings(patchedSettings), { + enabled: false, + subscribed: false, + }); + + useEffect(() => { + const handleBeforeUnload = () => { + setSettingsChanged(false); + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, []); + + const saveChangedSettings = () => { + if (!settings) + return; + + writeQuery.refetch(); + + setSettingsChanged(false); + }; + + const handleSetSettingsToSave = useCallback((newSettings: Partial | null) => { + setSettingsToSave(newSettings); + setSettingsChanged(true); + }, []); + + const createSetting = useCallback(( + name: K, + initialValue: V, + ): [V, (value: V) => void] => { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + const didChange = settings?.[name] !== value; + + if (didChange && settings) { + setSettingsToSave(prev => ({ + ...prev, + [name]: value, + })); + setSettingsChanged(true); + } + }, [value, name]); + + return [value, setValue]; + }, [settings]); + + const controller: SettingsControllerType = { + settings, + saveChangedSettings, + settingsToSave, + setSettingsToSave: handleSetSettingsToSave, + settingsChanged, + setSettingsChanged, + createSetting, + }; + + if (settings === undefined) + return null; + + return ( + + {children} + + {settingsChanged && ( +
+
+ You have unsaved changes! + +
+
+ )} +
+ ); +} + +export function useSettings(): SettingsControllerType { + const context = useContext(SettingsContext); + + if (!context) + throw new Error('useSettings should be called inside its SettingsProvider'); + + return context; +} + +export default useSettings; diff --git a/apps/onelauncher/frontend/src/main.tsx b/apps/onelauncher/frontend/src/main.tsx index b9cf6e98..930f49bd 100644 --- a/apps/onelauncher/frontend/src/main.tsx +++ b/apps/onelauncher/frontend/src/main.tsx @@ -4,8 +4,10 @@ import { createRouter, RouterProvider } from '@tanstack/react-router'; import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import { createTauRPCProxy } from './bindings.gen'; -import { routeTree } from './routeTree.gen'; +import { NotificationProvider } from './hooks/useNotification'; +import { SettingsProvider } from './hooks/useSettings'; +import { routeTree } from './routeTree.gen'; import './fonts'; import 'overlayscrollbars/overlayscrollbars.css'; import './styles/global.css'; @@ -55,7 +57,11 @@ if (rootElement && !rootElement.innerHTML) { root.render( - + + + + + , ); diff --git a/apps/onelauncher/frontend/src/routes/__root.tsx b/apps/onelauncher/frontend/src/routes/__root.tsx index f1d572ca..66e31747 100644 --- a/apps/onelauncher/frontend/src/routes/__root.tsx +++ b/apps/onelauncher/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 NotificationToasts from '@/components/overlay/NotificationToasts'; import WindowFrame from '@/components/WindowFrame'; import { AnimatedOutletProvider } from '@onelauncher/common/components'; import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router'; @@ -34,6 +35,7 @@ function RootRoute() { +
diff --git a/apps/onelauncher/frontend/src/routes/app/browser/index.tsx b/apps/onelauncher/frontend/src/routes/app/browser/index.tsx index 1e797315..43cc0b81 100644 --- a/apps/onelauncher/frontend/src/routes/app/browser/index.tsx +++ b/apps/onelauncher/frontend/src/routes/app/browser/index.tsx @@ -1,6 +1,9 @@ -import Modal from '@/components/overlay/Modal'; -import { Button, Dropdown, TextField } from '@onelauncher/common/components'; -import { createFileRoute } from '@tanstack/react-router'; +import OneConfigLogo from '@/assets/logos/oneconfig.svg'; +import { useBrowserContext, useBrowserSearch } from '@/hooks/useBrowser'; +import { Button, Show } from '@onelauncher/common/components'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { ChevronRightIcon } from '@untitled-theme/icons-react'; +import { BrowserLayout } from './route'; export const Route = createFileRoute('/app/browser/')({ component: RouteComponent, @@ -8,24 +11,52 @@ export const Route = createFileRoute('/app/browser/')({ function RouteComponent() { return ( -
-

asdasdas

- - - - {/* we can open the modal but we cant close it for some reason */} - - - - -

sadsadsad

-
-
- - - Slmalr - Slmalr 2 - -
+ +
+ +
+
+ ); +} + +function Featured() { + const context = useBrowserContext(); + const search = useBrowserSearch(context.provider, context.query); + return ( + +
+
Featured
+
+
+ thumbnail +
+
+

Sodium

+ + +
+ OneConfig Logo + OneConfig Integrated +
+
+ +

Peak mod

+ +
+ +
+
+
+ {search.status} + {search.isSuccess && ( + <> + {search.data.items.map(item =>

{item.name}

)} + + )} +
+
); } diff --git a/apps/onelauncher/frontend/src/routes/app/browser/route.tsx b/apps/onelauncher/frontend/src/routes/app/browser/route.tsx new file mode 100644 index 00000000..b15a71d6 --- /dev/null +++ b/apps/onelauncher/frontend/src/routes/app/browser/route.tsx @@ -0,0 +1,146 @@ +import type { Filters } from '@/bindings.gen'; +import ProviderIcon from '@/components/content/ProviderIcon'; +import { BrowserProvider, useBrowserContext } from '@/hooks/useBrowser'; +import { useClusters } from '@/hooks/useCluster'; +import { PROVIDERS } from '@/utils'; +import { browserCategories } from '@/utils/browser'; +import { Dropdown, Show, TextField } from '@onelauncher/common/components'; +import { createFileRoute, Outlet } from '@tanstack/react-router'; +import { SearchMdIcon } from '@untitled-theme/icons-react'; + +export const Route = createFileRoute('/app/browser')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( + +
+ +
+
+ ); +} + +export function BrowserLayout(props: any) { + return ( +
+
+
+
+ +
+
+ +
+ + +
+
+ {props.children} +
+
+ + +
+
+
+ ); +} + +function BrowserSidebar() { + const context = useBrowserContext(); + const clusters = useClusters(); + return ( +
+
+
+
Active Cluster
+ { + const cluster = clusters?.find(cluster => cluster.id.toString() === id); + context.setCluster(cluster); + }} + selectedKey={context.cluster?.id.toString()} + > + {clusters?.map(cluster => ( + + {cluster.name} + + ))} + +
+
+
Provider
+ context.setProvider(id as typeof context.provider)} selectedKey={context.provider}> + {PROVIDERS.map(provider => ( + +
+ + {provider} +
+
+ ))} +
+
+
+
+ ); +} + +const defaultFilters:Filters = { + categories: null, + game_versions: null, + loaders: null, + package_types: null +} + +function BrowserCategories() { + const context = useBrowserContext(); + const categories = browserCategories.byPackageType('mod', context.provider); + + function switchCategory(category:string){ + if(context.query.filters?.categories?.includes(category)) + context.setQuery({...context.query, filters: {...defaultFilters, ...context.query.filters, categories: (context.query.filters?.categories ?? []).filter(cat=>cat!==category)}}) + else + context.setQuery({...context.query, filters: {...defaultFilters, ...context.query.filters, categories: [...(context.query.filters?.categories ?? []), category]}}) + } + + return ( +
+
+
+ +
+
Categories
+ {categories.map(category => ( +

switchCategory(category.id)} + > + {category.display} +

+ ))} +
+
+
+
+ ); +} + +function BrowserToolbar() { + return ( +
+
+ +
+ } + placeholder="Search for content" + /> +
+
+ ); +} diff --git a/apps/onelauncher/frontend/src/routes/app/cluster/index.tsx b/apps/onelauncher/frontend/src/routes/app/cluster/index.tsx index 18084b63..65139f4a 100644 --- a/apps/onelauncher/frontend/src/routes/app/cluster/index.tsx +++ b/apps/onelauncher/frontend/src/routes/app/cluster/index.tsx @@ -1,14 +1,21 @@ -import type { Model } from '@/bindings.gen'; +import type { ClusterModel } from '@/bindings.gen'; import type { Dispatch, SetStateAction } from 'react'; +import DefaultInstancePhoto from '@/assets/images/default_instance_cover.jpg'; import LoaderIcon from '@/components/launcher/LoaderIcon'; import ScrollableContainer from '@/components/ScrollableContainer'; import SettingsRow from '@/components/SettingsRow'; +import useNotifications from '@/hooks/useNotification'; import { bindings } from '@/main'; +import { formatAsDuration } from '@/utils'; import { useCommand } from '@onelauncher/common'; import { Button, Show, TextField } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { dataDir, join } from '@tauri-apps/api/path'; +import { open } from '@tauri-apps/plugin-dialog'; +import { openPath } from '@tauri-apps/plugin-opener'; import { Edit02Icon, FolderIcon, ImagePlusIcon, Share07Icon, Tool02Icon, Trash01Icon } from '@untitled-theme/icons-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import Sidebar from '../settings/route'; @@ -18,13 +25,68 @@ export const Route = createFileRoute('/app/cluster/')({ function RouteComponent() { const { id } = Route.useSearch(); - const [newCover, setNewCover] = useState(''); - const [newName, setNewName] = useState(''); - const [edit, setEdit] = useState(false); - - // dumbass fix ik const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint)); + // dumbass fix ik + const [newCover, setNewCover] = useState(cluster.data?.icon_url as string); + const [newName, setNewName] = useState(cluster.data?.name as string); + const [edit, setEdit] = useState(false); + + const editing = useCommand('updateClusterById', () => bindings.core.updateClusterById(Number(id.toString()) as unknown as bigint, { + icon_url: newCover, + name: newName, + }), { + enabled: false, + subscribed: false, + }); + + const [launcherDir, setLauncherDir] = useState(''); + + useEffect(() => { + (async () => { + setLauncherDir(await join(await dataDir(), 'OneLauncher', 'clusters', cluster.data?.folder_name as string)); + })(); + }, [cluster.data?.folder_name]); + + const openClusterDir = async () => { + openPath(launcherDir); + }; + + async function handleEditMode() { + if (edit) + await editing.refetch(); + + setEdit(!edit); + } + /* + const { set, remove } = useNotifications(); + + useEffect(() => { + let unlisten: (() => void) | undefined; + (async () => { + unlisten = await bindings.events.ingress.on((e) => { + if (typeof e.ingress_type === 'object') + if ('PrepareCluster' in e.ingress_type) { + const { cluster_name } = e.ingress_type.PrepareCluster; + let cid = `cluster-${cluster_name}`; + + if (e.percent === null) { + remove(cid); + return; + } + + set(cid, { + title: 'Preparing Cluster', + message: e.message, + fraction: e.percent, + }); + } + }); + })(); + + return () => unlisten?.(); + }, [set, remove]); +*/ return (

Overview

@@ -47,9 +109,10 @@ function RouteComponent() { children="Open" color="primary" isDisabled={false} + onClick={openClusterDir} /> )} - description="burada path var işte yersen" + description={launcherDir} disabled={false} icon={} title="Cluster Folder" @@ -58,7 +121,7 @@ function RouteComponent() { Cluster Actions setEdit(!edit)}>{edit ? 'Save' : 'Edit'} + )} description="Edit the cluster name and cover image." icon={} @@ -95,7 +158,7 @@ function RouteComponent() { } interface BannerProps { - cluster: Model | null | undefined; + cluster: ClusterModel | null | undefined; editMode: boolean; newName: string; setNewName: Dispatch>; @@ -107,23 +170,28 @@ interface BannerProps { function Banner({ cluster, editMode, + newCover, setNewName, + setNewCover, }: BannerProps) { - // async function launchFilePicker() { - // const selected = await dialog.open({ - // multiple: false, - // directory: false, - // filters: [{ - // name: 'Image', - // extensions: ['png', 'jpg', 'jpeg', 'webp'], - // }], - // }); + const { set } = useNotifications(); - // if (selected === null) - // return; + async function launchFilePicker() { + const selected = await open({ + multiple: false, + directory: false, + filters: [{ + name: 'Image', + extensions: ['png', 'jpg', 'jpeg', 'webp'], + }], + }); + + if (selected === null) + return; + + setNewCover(selected); + } - // props.setNewCover(selected); - // } const launch = useCommand('launchCluster', () => bindings.core.launchCluster(cluster?.id as bigint, null), { enabled: false, subscribed: false, @@ -132,8 +200,11 @@ function Banner({ const handleLaunch = () => { launch.refetch(); - if (launch.error) - console.error(launch.error.message); + if (launch.isError) + set('launch_cluster', { + title: 'Failed to launch cluster', + message: launch.error.message, + }); }; function updateName(name: string) { @@ -143,20 +214,42 @@ function Banner({ setNewName(name); } + const image = () => { + if (editMode && newCover && newCover !== cluster?.icon_url) + if (newCover.includes('\\') || newCover.includes('/')) { + console.warn('Using preview image:', newCover); + return convertFileSrc(newCover); + } + + const url = cluster?.icon_url; + + if (!url) + return DefaultInstancePhoto; + + return convertFileSrc(url); + }; + return (
{/* */} - + { + console.error('Failed to load cluster icon:', image(), e); + (e.target as HTMLImageElement).src = DefaultInstancePhoto; + }} + src={image()} + />
@@ -193,7 +286,7 @@ function Banner({ > Played for {' '} - {cluster?.overall_played || 0} + {formatAsDuration(cluster?.overall_played || 0)} .
@@ -204,8 +297,7 @@ function Banner({
diff --git a/apps/onelauncher/frontend/src/routes/app/cluster/logs.tsx b/apps/onelauncher/frontend/src/routes/app/cluster/logs.tsx new file mode 100644 index 00000000..59e5c9f5 --- /dev/null +++ b/apps/onelauncher/frontend/src/routes/app/cluster/logs.tsx @@ -0,0 +1,223 @@ +import type { ProcessPayload } from '@/bindings.gen'; +import ScrollableContainer from '@/components/ScrollableContainer'; +import { bindings } from '@/main'; +import { useCommand } from '@onelauncher/common'; +import { Button, Show } from '@onelauncher/common/components'; +import { createFileRoute } from '@tanstack/react-router'; +import { dataDir, join } from '@tauri-apps/api/path'; +import { openPath } from '@tauri-apps/plugin-opener'; +import { LinkExternal01Icon, Upload01Icon } from '@untitled-theme/icons-react'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import { useEffect } from 'react'; +import { proxy, useSnapshot } from 'valtio'; +import Sidebar from '../settings/route'; + +export const Route = createFileRoute('/app/cluster/logs')({ + component: RouteComponent, +}); + +const store = proxy({ + logs: [] as Array, +}); + +function RouteComponent() { + const { id: _id } = Route.useSearch(); + const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(_id.toString()) as unknown as bigint)); + const state = useSnapshot(store); + + const addLog = (newLog: ProcessPayload) => { + store.logs.push(newLog); + }; + + useEffect(() => { + let cleanup: (() => void) | undefined; + + bindings.events.process.on(addLog).then((e) => { + cleanup = e; + }).catch(console.error); + + return () => { + cleanup?.(); + }; + }, []); + + const openLogsFolder = async () => { + openPath(await join(await dataDir(), 'OneLauncher', 'clusters', cluster.data?.folder_name as string, 'logs')); + }; + + return ( + + +
+
+
+
+

Logs

+
+ + + {/* + + {(log) => { + const pretty = log.split('/').pop(); + return ( + +
+ {pretty} +
+
+ ); + }} +
+
*/} + + +
+
+ + No logs were found.} + when={state.logs.length > 0} + > + + {/* {JSON.stringify(state.logs)} */} + +
+
+
+
+
+ ); +} + +interface FormattedLogProps { + log: ReadonlyArray; +} + +function FormattedLog(props: FormattedLogProps) { + const { log } = props; + + return ( +
+ +
+ +
+ + + {log.map((logLine, index) => { + if (index === log.length - 1) + return null; + + // trickery fuckery + const lineKey = `${logLine.type}-${index}-${(logLine.type === 'Output' ? logLine.output : '').slice(0, 50)}`; + return ; + })} + + +
+
+ ); +} + +const REGEX_PATTERN = /\[(\d+:\d+:\d+)\] \[(.[^(\n\r/\u2028\u2029]*)\/(\w+)\]:? (?:\[(CHAT)\])?/; + +export function Line(props: { line: ProcessPayload }) { + const { line } = props; + + const format = (logText: string) => { + let processedLine = logText.replace(/§./g, ''); + const prefix = processedLine.match(REGEX_PATTERN); + + const isEmpty = processedLine.trim() === ''; + + if (isEmpty) + processedLine = '\n'; + + if (prefix === null) + return ( + + {processedLine} + + ); + + const isChatMsg = prefix[4] === 'CHAT'; + const logLevel = prefix[3].toUpperCase(); + + const getLevelColor = (level: string) => { + switch (level) { + case 'INFO': + return 'text-code-info'; + case 'DEBUG': + return 'text-code-debug'; + case 'TRACE': + return 'text-code-trace'; + case 'WARN': + return 'text-code-warn'; + case 'ERROR': + return 'text-code-error'; + default: + return 'text-fg-primary'; + } + }; + + return ( + + + {prefix[1]} + + + {`[${prefix[2]}/${prefix[3]}]`} + + {isChatMsg && ( + + CHAT + + )} + + {processedLine.slice(prefix[0].length)} + + + ); + }; + + return ( + <>{format(line.type === 'Output' ? line.output : '')} + ); +} diff --git a/apps/onelauncher/frontend/src/routes/app/cluster/screenshots.tsx b/apps/onelauncher/frontend/src/routes/app/cluster/screenshots.tsx index 9353c645..78f3be17 100644 --- a/apps/onelauncher/frontend/src/routes/app/cluster/screenshots.tsx +++ b/apps/onelauncher/frontend/src/routes/app/cluster/screenshots.tsx @@ -1,6 +1,11 @@ import ScrollableContainer from '@/components/ScrollableContainer'; +import { bindings } from '@/main'; +import { useCommand } from '@onelauncher/common'; import { Show } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { dataDir, join } from '@tauri-apps/api/path'; +import { useEffect, useState } from 'react'; import Sidebar from '../settings/route'; export const Route = createFileRoute('/app/cluster/screenshots')({ @@ -8,7 +13,10 @@ export const Route = createFileRoute('/app/cluster/screenshots')({ }); function RouteComponent() { - const arr = [1, 2, 3, 4, 5, 6]; + const { id } = Route.useSearch(); + const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint)); + const result = useCommand('getScreenshots', () => bindings.core.getScreenshots(Number(id.toString()) as unknown as bigint)); + return (

Screenshots

@@ -16,11 +24,12 @@ function RouteComponent() {
No screenshots found. Press F2 in game to take a screenshot!
} - when + // @ts-ignore idk + when={result.data?.length > 0} >
- {arr.map(data => ( - + {result.data?.map(data => ( + ))}
@@ -32,20 +41,34 @@ function RouteComponent() { function ScreenshotEntry({ path, + cluster_path, }: { path: string; cluster_path: string; }) { - function onClick() { - // screen shot viewer overlay - } + const [imagePath, setImagePath] = useState(''); + + useEffect(() => { + async function loadImagePath() { + const screenshotsDir = await join(await dataDir(), 'OneLauncher', 'clusters', cluster_path, 'screenshots'); + const fullPath = await join(screenshotsDir, path); + setImagePath(convertFileSrc(fullPath)); + } + + loadImagePath().catch(console.error); + }, [cluster_path, path]); return ( -
- {path} -
+ <> +
+ {path} +
+ ); } diff --git a/apps/onelauncher/frontend/src/routes/app/cluster/settings.tsx b/apps/onelauncher/frontend/src/routes/app/cluster/settings.tsx index 2efdc652..c3277183 100644 --- a/apps/onelauncher/frontend/src/routes/app/cluster/settings.tsx +++ b/apps/onelauncher/frontend/src/routes/app/cluster/settings.tsx @@ -1,4 +1,5 @@ import ScrollableContainer from '@/components/ScrollableContainer'; +import usePopState from '@/hooks/usePopState'; import { bindings } from '@/main'; import { useCommand } from '@onelauncher/common'; import { createFileRoute } from '@tanstack/react-router'; @@ -13,19 +14,31 @@ function RouteComponent() { const { id } = Route.useSearch(); const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint)); - const _result = useCommand('getProfileOrDefault', () => bindings.core.getProfileOrDefault(cluster.data?.name as string), { - enabled: !!cluster.data?.name, + const _result = useCommand('getProfileOrDefault', () => bindings.core.getProfileOrDefault(cluster.data?.setting_profile_name as string), { + enabled: !!cluster.data?.setting_profile_name, }); + const save = useCommand('updateClusterProfile', () => bindings.core.updateClusterProfile(cluster.data?.name as string, _result.data!), { + enabled: false, + subscribed: false, + }); + + usePopState(() => { + save.refetch(); + }); + + if (_result.isPending) + return

loading...

; + return (

Minecraft Settings

- + - +
diff --git a/apps/onelauncher/frontend/src/routes/app/cluster/worlds.tsx b/apps/onelauncher/frontend/src/routes/app/cluster/worlds.tsx index c1ea46be..00040da9 100644 --- a/apps/onelauncher/frontend/src/routes/app/cluster/worlds.tsx +++ b/apps/onelauncher/frontend/src/routes/app/cluster/worlds.tsx @@ -1,5 +1,10 @@ +import WorldIcon from '@/components/content/WorldIcon'; import ScrollableContainer from '@/components/ScrollableContainer'; +import { bindings } from '@/main'; +import { useCommand } from '@onelauncher/common'; +import { Button, Show } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; +import { Trash01Icon } from '@untitled-theme/icons-react'; import Sidebar from '../settings/route'; export const Route = createFileRoute('/app/cluster/worlds')({ @@ -7,14 +12,52 @@ export const Route = createFileRoute('/app/cluster/worlds')({ }); function RouteComponent() { + const { id } = Route.useSearch(); + const cluster = useCommand('getClusterById', () => bindings.core.getClusterById(Number(id.toString()) as unknown as bigint)); + const list = useCommand('getWorlds', () => bindings.core.getWorlds(Number(id.toString()) as unknown as bigint)); + return (

Worlds

- + No worlds found.
} + when={list.data !== undefined && list.data.length > 0} + > +
+ {list.data?.map(data => ( + + ))} +
+
); } + +function WorldEntry(props: { name: string; cluster_path: string }) { + const { name, cluster_path } = props; + + return ( +
+
+ +
+

{name}

+

Todo

+
+
+ +
+
+
+ ); +} diff --git a/apps/onelauncher/frontend/src/routes/app/dev.tsx b/apps/onelauncher/frontend/src/routes/app/dev.tsx new file mode 100644 index 00000000..c7045a91 --- /dev/null +++ b/apps/onelauncher/frontend/src/routes/app/dev.tsx @@ -0,0 +1,110 @@ +import Modal from '@/components/overlay/Modal'; +import useNotifications from '@/hooks/useNotification'; +import useSettings from '@/hooks/useSettings'; +import { bindings } from '@/main'; +import { useCommand } from '@onelauncher/common'; +import { Button, Dropdown, Switch, TextField } from '@onelauncher/common/components'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/app/dev')({ + component: RouteComponent, +}); + +function RouteComponent() { + const { create, list, clear } = useNotifications(); + const { settings, createSetting } = useSettings(); + const result = useCommand('read_settings', bindings.core.readSettings); + + const addSimpleNotif = () => { + create({ + message: 'This is a simple notification message', + title: 'Simple Notification', + }); + }; + + const addProgressNotif = () => { + create({ + message: 'Download in progress...', + title: 'Downloading File', + fraction: Math.floor(Math.random() * 100), + }); + }; + + const addErrorNotif = () => { + create({ + message: 'Something went wrong while processing your request', + title: 'Error', + }); + }; + + const [parallel, setParallel] = createSetting('allow_parallel_running_clusters', settings?.allow_parallel_running_clusters); + + return ( +
+

this is really became my test place fr

+ + + + {/* we can open the modal but we cant close it for some reason */} + + + + +

sadsadsad

+
+
+ +
+ + Slmalr + Slmalr 2 + +
+ +
+ + + + +
+ +
+

+ Current Notifications ( + {Object.keys(list).length} + ): +

+
+					{JSON.stringify(list, null, 2)}
+				
+
+ +
+
+
+

Settings

+
+							{JSON.stringify(result.data, null, 2)}
+						
+
+ +
+

Settings again but this time its from useSettings hook

+
+							{JSON.stringify(settings, null, 2)}
+						
+
+
+ +
+
+
+							{parallel ? 'Parallel running is enabled' : 'Parallel running is disabled'}
+						
+ setParallel(val)} /> +
+
+
+
+ ); +} diff --git a/apps/onelauncher/frontend/src/routes/app/index.tsx b/apps/onelauncher/frontend/src/routes/app/index.tsx index 21c9528b..4b66d45a 100644 --- a/apps/onelauncher/frontend/src/routes/app/index.tsx +++ b/apps/onelauncher/frontend/src/routes/app/index.tsx @@ -1,17 +1,15 @@ -import type { Model } from '@/bindings.gen'; +import type { ClusterModel } from '@/bindings.gen'; import DefaultBanner from '@/assets/images/default_banner.png'; import DefaultInstancePhoto from '@/assets/images/default_instance_cover.jpg'; import { NewClusterCreate } from '@/components/launcher/cluster/ClusterCreation'; -import { NewClusterCreate } from '@/components/launcher/cluster/ClusterCreation'; -import Modal from '@/components/overlay/Modal'; -import useRecentCluster from '@/hooks/useCluster'; +import { useRecentCluster } from '@/hooks/useCluster'; import { bindings } from '@/main'; -import { upperFirst } from '@/utils'; -import { useCommand } from '@onelauncher/common'; -import { Button, Show, TextField } from '@onelauncher/common/components'; +import { formatAsDuration, upperFirst } from '@/utils'; +import { useCommand, useCommandMut, useCommandSuspense } from '@onelauncher/common'; +import { Button, Show } from '@onelauncher/common/components'; import { createFileRoute, Link } from '@tanstack/react-router'; -import { PlayIcon, Server01Icon } from '@untitled-theme/icons-react'; -import { PlayIcon, Server01Icon } from '@untitled-theme/icons-react'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { PlayIcon } from '@untitled-theme/icons-react'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; export const Route = createFileRoute('/app/')({ @@ -58,32 +56,46 @@ function Banner() { // const launch = useLaunchCluster(() => cluster()?.uuid); // const navigate = useNavigate(); + const image = () => { + const url = cluster?.icon_url; + + if (!url) + return DefaultBanner; + + return convertFileSrc(url); + }; + return (
- Default Banner +
+ Default Banner { + (e.target as HTMLImageElement).src = DefaultBanner; + }} + src={image()} + /> +
-

Create a cluster

+

{cluster?.name || 'Create a cluster'}

You've played {' '} - {cluster?.name} + {cluster?.mc_version} {' '} {upperFirst(cluster?.mc_loader || 'Unknown')} {' '} for {' '} - {cluster?.overall_played || 0} + {formatAsDuration(cluster?.overall_played || 0)} .

@@ -117,7 +129,7 @@ function Banner() { } interface ClusterGroupProps { - clusters: Array | undefined; + clusters: Array | undefined; isFetching?: boolean; } @@ -152,37 +164,35 @@ function ClusterCard({ name, mc_loader, mc_version, -}: Model) { - const launch = useCommand('launchCluster', () => bindings.core.launchCluster(id, null), { - enabled: false, - subscribed: false, - }); + icon_url, + stage, +}: ClusterModel) { + const launch = useCommandMut(() => bindings.core.launchCluster(id, null)); const handleLaunch = () => { - launch.refetch(); + launch.mutate(); if (launch.error) console.error(launch.error.message); }; - const launch = useCommand('launchCluster', () => bindings.core.launchCluster(id, null), { - enabled: false, - subscribed: false, - }); + const image = () => { + const url = icon_url; - const handleLaunch = () => { - launch.refetch(); + if (!url) + return DefaultInstancePhoto; - if (launch.error) - console.error(launch.error.message); + return convertFileSrc(url); }; return ( <>
{ + (e.target as HTMLImageElement).src = DefaultInstancePhoto; + }} + src={image()} />
-

{name}

-

- {mc_loader} - {' '} - {mc_version} - {/* {' '} - <> - -

-
-
- -
-
-
-
-

{name}

-

- {mc_loader} - {' '} - {mc_version} - {/* {' '} - {props.packages.mods && `• ${props.mods} mods`} */} +

+ {name}

-
+

+ {mc_loader} + {' '} + {mc_version}

diff --git a/apps/onelauncher/frontend/src/routes/app/settings/accounts.tsx b/apps/onelauncher/frontend/src/routes/app/settings/accounts.tsx index 21056bd4..da500e3e 100644 --- a/apps/onelauncher/frontend/src/routes/app/settings/accounts.tsx +++ b/apps/onelauncher/frontend/src/routes/app/settings/accounts.tsx @@ -21,6 +21,11 @@ function RouteComponent() { const { data: usersData, isLoading: usersLoading } = useCommand('getUsers', bindings.core.getUsers); const { data: defaultUserData, isLoading: defaultUserLoading } = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false)); + const addAccount = useCommand('openMsaLogin', bindings.core.openMsaLogin, { + enabled: false, + subscribed: false, + }); + if (usersLoading || defaultUserLoading) return ( @@ -47,7 +52,7 @@ function RouteComponent() {
-
@@ -65,21 +99,12 @@ export function GameSettings() { >
- Min: -
- -
- Max: - { + settings.mem_max = Number(e.target.value); + }} type="number" />
@@ -104,7 +129,13 @@ export function GameSettings() { // ); // } -export function ProcessSettings() { +interface ProcessSettingsProps { + settings: SettingProfileModel; +} + +export function ProcessSettings(props: ProcessSettingsProps) { + const { settings } = props; + return ( <> Process @@ -115,6 +146,10 @@ export function ProcessSettings() { title="Pre-Launch Command" > { + settings.hook_pre = e.target.value || null; + }} placeholder="echo 'Game started'" /> @@ -125,6 +160,10 @@ export function ProcessSettings() { title="Wrapper Command" > { + settings.hook_wrapper = e.target.value || null; + }} placeholder="gamescope" /> @@ -135,6 +174,10 @@ export function ProcessSettings() { title="Post-Exit Command" > { + settings.hook_post = e.target.value || null; + }} placeholder="echo 'Game exited'" /> diff --git a/apps/onelauncher/frontend/src/routes/app/settings/route.tsx b/apps/onelauncher/frontend/src/routes/app/settings/route.tsx index 3ebb7cc6..644e41bb 100644 --- a/apps/onelauncher/frontend/src/routes/app/settings/route.tsx +++ b/apps/onelauncher/frontend/src/routes/app/settings/route.tsx @@ -69,8 +69,19 @@ function Sidebar(props: SidebarProps) { navigate({ to: url }); } - function isActive(link: string, _params: URLSearchParams | undefined) { - return location === `${base}${link}` || `${location}/` === `${base}${link}`; + function isActive(link: string, params: URLSearchParams | undefined) { + const fullBasePath = `${base}${link}`; + const pathnameMatch = location.startsWith(fullBasePath) || `${location}/`.startsWith(fullBasePath); + + if (!params) + return pathnameMatch; + + const searchParams = new URLSearchParams(routerState.location.searchStr); + for (const [key, value] of params) + if (searchParams.get(key) !== value) + return false; + + return pathnameMatch; } return ( @@ -78,7 +89,7 @@ function Sidebar(props: SidebarProps) { {Object.keys(links).map((section, i) => (
-

{section.toUpperCase()}

+

{section.toUpperCase()}

{links[section].map((link, i) => { // eslint yaps too much tbh @@ -88,7 +99,7 @@ function Sidebar(props: SidebarProps) { return ( goto(link[2], link[3])} diff --git a/apps/onelauncher/frontend/src/routes/onboarding/login.tsx b/apps/onelauncher/frontend/src/routes/onboarding/login.tsx index 1d7f3410..0e6ab954 100644 --- a/apps/onelauncher/frontend/src/routes/onboarding/login.tsx +++ b/apps/onelauncher/frontend/src/routes/onboarding/login.tsx @@ -5,7 +5,6 @@ import { useCommand } from '@onelauncher/common'; import { Button, Show } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; import { useState } from 'react'; -import { useOnboardingContext } from './route'; export const Route = createFileRoute('/onboarding/login')({ component: RouteComponent, diff --git a/apps/onelauncher/frontend/src/styles/global.css b/apps/onelauncher/frontend/src/styles/global.css index 34cfceb8..06a0ee4a 100644 --- a/apps/onelauncher/frontend/src/styles/global.css +++ b/apps/onelauncher/frontend/src/styles/global.css @@ -13,3 +13,20 @@ --radius-3xl: 1.5rem; --radius-4xl: 2rem; } + +/* i dont know where to put this */ +.linearBlur::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + -webkit-backdrop-filter: blur(30); + backdrop-filter: blur(30); + + -webkit-mask: linear-gradient(270deg, transparent, black 50%); + mask: linear-gradient(270deg, transparent, black 50%); + + background: linear-gradient(270deg, transparent, rgba(0, 0, 0, 0.85) 80%); +} \ No newline at end of file diff --git a/apps/onelauncher/frontend/src/utils/browser.ts b/apps/onelauncher/frontend/src/utils/browser.ts new file mode 100644 index 00000000..375db1d3 --- /dev/null +++ b/apps/onelauncher/frontend/src/utils/browser.ts @@ -0,0 +1,95 @@ +export const BROWSER_VIEWS: Array = ['grid', 'list'] as const; + +export interface CategoryItem { + display: string; + id: string; +} + +export const browserCategories = { + byPackageType(packageType: 'mod' | string, provider: string): Array { + switch (packageType) { + case 'mod': + return this.mod(provider); + } + + return []; + }, + + mod(provider: string = 'Modrinth'): Array { + return Object.values(modMapping[provider]); + }, +}; + +const modMapping: Record> = { + Curseforge: [ + { display: 'Adventure', id: '422' }, + { display: 'API and Library', id: '421' }, + { display: 'Armor and Tools', id: '434' }, + { display: 'Biomes', id: '407' }, + { display: 'Bug Fixes', id: '6821' }, + { display: 'Cosmetic', id: '424' }, + { display: 'Dimensions', id: '410' }, + { display: 'Education', id: '5299' }, + { display: 'Energy', id: '417' }, + { display: 'Farming', id: '416' }, + { display: 'Food', id: '436' }, + { display: 'Forestry', id: '433' }, + { display: 'Magic', id: '419' }, + { display: 'Miscellaneous', id: '425' }, + { display: 'Mobs', id: '411' }, + { display: 'Ores and Resources', id: '408' }, + { display: 'Performance', id: '6814' }, + { display: 'Player Transport', id: '414' }, + { display: 'Redstone', id: '4558' }, + { display: 'Storage', id: '420' }, + { display: 'Structures', id: '409' }, + { display: 'Technology', id: '412' }, + { display: 'Utility & QoL', id: '5191' }, + { display: 'World Generation', id: '406' }, + + // { display: 'Map and Information', id: '423' }, + // { display: 'Server Utility', id: '435' }, + // { display: 'Twitch Integration', id: '4671' }, + // { display: 'Create', id: '6484' }, + // { display: 'Buildcraft', id: '432' }, + // { display: 'Industrial Craft', id: '429' }, + // { display: 'KubeJS', id: '5314' }, + // { display: 'Genetics', id: '418' }, + // { display: 'Blood Magic', id: '4485' }, + // { display: 'Automation', id: '4843' }, + // { display: 'CraftTweaker', id: '4773' }, + // { display: 'Addons', id: '426' }, + // { display: 'Energy, Fluid, and Item Transport', id: '415' }, + // { display: 'Thermal Expansion', id: '427' }, + // { display: 'Thaumcraft', id: '430' }, + // { display: 'Processing', id: '413' }, + // { display: 'Tinker\'s Construct', id: '428' }, + // { display: 'Skyblock', id: '6145' }, + // { display: 'Galacticraft', id: '5232' }, + // { display: 'Applied Energistics 2', id: '4545' }, + // { display: 'Integrated Dynamics', id: '6954' }, + // { display: 'MCreator', id: '4906' }, + ], + Modrinth: [ + { display: 'Adventure', id: 'adventure' }, + { display: 'Cursed', id: 'cursed' }, + { display: 'Decoration', id: 'decoration' }, + { display: 'Economy', id: 'economy' }, + { display: 'Equipment', id: 'equipment' }, + { display: 'Food', id: 'food' }, + { display: 'Game Mechanics', id: 'game-mechanics' }, + { display: 'Library', id: 'library' }, + { display: 'Magic', id: 'magic' }, + { display: 'Management', id: 'management' }, + { display: 'Minigame', id: 'minigame' }, + { display: 'Mobs', id: 'mobs' }, + { display: 'Optimization', id: 'optimization' }, + { display: 'Social', id: 'social' }, + { display: 'Storage', id: 'storage' }, + { display: 'Technology', id: 'technology' }, + { display: 'Transportation', id: 'transportation' }, + { display: 'Utility', id: 'utility' }, + { display: 'World Generation', id: 'worldgen' }, + ], + SkyClient: [], +}; diff --git a/apps/onelauncher/frontend/src/utils/index.ts b/apps/onelauncher/frontend/src/utils/index.ts index a82318da..9b4f923f 100644 --- a/apps/onelauncher/frontend/src/utils/index.ts +++ b/apps/onelauncher/frontend/src/utils/index.ts @@ -1,4 +1,4 @@ -export const LAUNCHER_IMPORT_TYPES: Array = [ +export const LAUNCHER_IMPORT_TYPES = [ 'PrismLauncher', 'Curseforge', // 'Modrinth', @@ -10,6 +10,17 @@ export const LAUNCHER_IMPORT_TYPES: Array = [ // 'Technic' ] as const; +export const LOADERS = [ + 'vanilla', + 'forge', + 'neoforge', + 'quilt', + 'fabric', + 'legacyfabric', +] as const; +export const PROVIDERS = ['Modrinth', 'Curseforge', 'SkyClient'] as const; +export const PACKAGE_TYPES = ['mod', 'resourcepack', 'datapack', 'shaderpack'] as const; + export function pluralize(n: number, word: string, locale: string = 'en'): string { const pluralRules = new Intl.PluralRules(locale); const pluralForm = pluralRules.select(n); @@ -77,3 +88,35 @@ export function formatAsRelative( return 'now'; } + +function convertSeconds(secondsInput: number): string { + let remaining = secondsInput; + + const hours = Math.floor(remaining / 3600); + remaining %= 3600; + + const minutes = Math.floor(remaining / 60); + remaining %= 60; + + const seconds = remaining; + + const parts: Array = []; + + if (hours) + parts.push(`${hours} hours`); + if (minutes) + parts.push(`${minutes} minutes`); + if (seconds) + parts.push(`${seconds} seconds`); + + return parts.join(' '); +} + +export function formatAsDuration(seconds: number | bigint | Date): string { + if (seconds instanceof Date) + seconds = seconds.getTime() / 1000; + else + seconds = Number(seconds); + + return convertSeconds(seconds); +} diff --git a/packages/core/examples/packages.rs b/packages/core/examples/packages.rs index 68b8eaf3..64bc84ce 100644 --- a/packages/core/examples/packages.rs +++ b/packages/core/examples/packages.rs @@ -1,4 +1,4 @@ -use onelauncher_core::{api::{cluster, packages::{self, download_package, provider::ProviderExt}, proxy::ProxyDynamic}, error::LauncherResult, initialize_core, store::{CoreOptions, Dirs}}; +use onelauncher_core::{api::{cluster, packages::{self, data::{SearchQuery, Sort}, download_package, provider::ProviderExt}, proxy::ProxyDynamic}, error::LauncherResult, initialize_core, store::{CoreOptions, Dirs}}; use onelauncher_entity::{loader::GameLoader, package::Provider}; #[tokio::main] @@ -19,6 +19,15 @@ pub async fn main() -> LauncherResult<()> { // Fetch our package and its versions let provider = Provider::Modrinth; + + println!("search results: {:#?}", provider.search(&SearchQuery { + query: Some("chatting".to_string()), + offset: Some(0), + limit: Some(10), + sort: Some(Sort::Newest), + filters: None, + }).await?); + let package = &provider.get("chatting").await?; let versions = provider.get_versions(package.version_ids.as_slice()).await?; diff --git a/packages/core/src/api/cluster/content.rs b/packages/core/src/api/cluster/content.rs new file mode 100644 index 00000000..d7bacaf5 --- /dev/null +++ b/packages/core/src/api/cluster/content.rs @@ -0,0 +1,39 @@ +use onelauncher_entity::clusters; + +use crate::{error::LauncherResult, store::Dirs, utils::io}; + +pub async fn get_screenshots(cluster: &clusters::Model) -> LauncherResult> { + let dir = cluster.folder_name.clone(); + let path = Dirs::get_clusters_dir().await?.join(dir).join("screenshots"); + + if !path.exists() { + io::create_dir(&path).await?; + return Ok(Vec::new()); + } + + let mut list = vec![]; + let mut files = io::read_dir(path).await?; + while let Ok(Some(entry)) = files.next_entry().await { + list.push(entry.file_name().to_string_lossy().to_string()); + } + + Ok(list) +} + +pub async fn get_worlds(cluster: &clusters::Model) -> LauncherResult> { + let dir = cluster.folder_name.clone(); + let path = Dirs::get_clusters_dir().await?.join(dir).join("saves"); + + if !path.exists() { + io::create_dir(&path).await?; + return Ok(Vec::new()); + } + + let mut list = vec![]; + let mut files = io::read_dir(path).await?; + while let Ok(Some(entry)) = files.next_entry().await { + list.push(entry.file_name().to_string_lossy().to_string()); + } + + Ok(list) +} diff --git a/packages/core/src/api/cluster/dao.rs b/packages/core/src/api/cluster/dao.rs index 4ee4693a..e8aaa516 100644 --- a/packages/core/src/api/cluster/dao.rs +++ b/packages/core/src/api/cluster/dao.rs @@ -3,7 +3,7 @@ use std::path::Path; use onelauncher_entity::{cluster_groups, clusters, icon::Icon, loader::GameLoader}; use sea_orm::{ActiveValue::Set, IntoActiveModel, prelude::*}; -use crate::{error::{DaoError, LauncherResult}, store::State}; +use crate::{error::{DaoError, LauncherResult}, store::State, utils::io}; pub type ClusterId = i64; @@ -155,3 +155,71 @@ pub async fn get_clusters_grouped() -> LauncherResult LauncherResult<()> { + let icon_path = Path::new(icon_path); + + if !icon_path.exists() { + return Err(anyhow::anyhow!("Icon file does not exist: {}", icon_path.display()).into()); + } + + let image_bytes = io::read(icon_path).await?; + + let hash = crate::utils::crypto::HashAlgorithm::Sha1.hash(&image_bytes).await?; + + let cache_dir = crate::store::Dirs::get_caches_dir().await?.join("icons"); + io::create_dir_all(&cache_dir).await?; + + let cached_file_path = cache_dir.join(format!("{}.png", hash)); + io::write(&cached_file_path, &image_bytes).await?; + + let file_icon = Icon::try_from_path(&cached_file_path) + .ok_or_else(|| anyhow::anyhow!("Failed to create file icon from path: {}", cached_file_path.display()))?; + + update_cluster(cluster, |mut active_model: clusters::ActiveModel| async move { + active_model.icon_url = Set(Some(file_icon)); + Ok(active_model) + }).await?; + + Ok(()) +} + +/// Caches an icon from a file path and updates the cluster by ID to use the cached icon. +/// This function copies the image to the icon cache directory and sets the cluster's icon_url +/// to reference the cached version. +pub async fn set_icon_by_id( + cluster_id: ClusterId, + icon_path: &str, +) -> LauncherResult { + let icon_path = Path::new(icon_path); + + if !icon_path.exists() { + return Err(anyhow::anyhow!("Icon file does not exist: {}", icon_path.display()).into()); + } + + let image_bytes = io::read(icon_path).await?; + + let hash = crate::utils::crypto::HashAlgorithm::Sha1.hash(&image_bytes).await?; + + let cache_dir = crate::store::Dirs::get_caches_dir().await?.join("icons"); + io::create_dir_all(&cache_dir).await?; + + let cached_file_path = cache_dir.join(format!("{}.png", hash)); + io::write(&cached_file_path, &image_bytes).await?; + + let file_icon = Icon::try_from_path(&cached_file_path) + .ok_or_else(|| anyhow::anyhow!("Failed to create file icon from path: {}", cached_file_path.display()))?; + + let updated_cluster = update_cluster_by_id(cluster_id, |mut active_model: clusters::ActiveModel| async move { + active_model.icon_url = Set(Some(file_icon)); + Ok(active_model) + }).await?; + + Ok(updated_cluster) +} diff --git a/packages/core/src/api/cluster/mod.rs b/packages/core/src/api/cluster/mod.rs index f565e69f..09b031d4 100644 --- a/packages/core/src/api/cluster/mod.rs +++ b/packages/core/src/api/cluster/mod.rs @@ -18,6 +18,7 @@ use crate::store::{Dirs, State}; use crate::utils::io::{self, IOError}; pub mod dao; +pub mod content; mod sync; pub use sync::*; diff --git a/packages/core/src/api/java/mod.rs b/packages/core/src/api/java/mod.rs index 1d284158..5114c902 100644 --- a/packages/core/src/api/java/mod.rs +++ b/packages/core/src/api/java/mod.rs @@ -1,14 +1,17 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{self, PathBuf}; +use async_zip::base::read::mem::ZipFileReader; use interpulse::api::minecraft::VersionInfo; use onelauncher_entity::{java_versions, setting_profiles}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::api::ingress::{init_ingress, init_ingress_opt, send_ingress}; use crate::error::LauncherResult; -use crate::store::ingress::IngressType; -use crate::utils::io; +use crate::store::ingress::{IngressType, SubIngress}; +use crate::store::{Dirs, State}; +use crate::utils::http::{fetch_advanced, fetch_json}; +use crate::utils::io::{self, IOError}; pub mod dao; @@ -16,9 +19,18 @@ pub mod dao; #[derive(Debug, thiserror::Error)] pub enum JavaError { #[error("failed to parse version '{0}'")] - ParseVersion(String, #[source] #[skip] std::num::ParseIntError), + ParseVersion( + String, + #[source] + #[skip] + std::num::ParseIntError, + ), #[error("failed to execute java command")] - Execute(#[from] #[skip] std::io::Error), + Execute( + #[from] + #[skip] + std::io::Error, + ), #[error("no java installations found")] MissingJava, } @@ -52,6 +64,14 @@ pub struct JavaInfo { pub java_vendor: String, } +#[onelauncher_macro::specta] +#[derive(Serialize, Deserialize)] +pub struct JavaPackage { + pub download_url: String, + pub name: PathBuf, + pub java_version: Vec, +} + const JAVA_INFO_CLASS: &[u8] = include_bytes!("../../../assets/java/JavaInfo.class"); /// Accepts a path to a java executable and returns the [`JavaInfo`] @@ -124,9 +144,10 @@ pub async fn get_recommended_java( if let Some(profile) = profile && !profile.is_global() && let Some(java_id) = profile.java_id - && let Ok(Some(java)) = dao::get_java_by_id(java_id).await { - return Ok(Some(java)); - } + && let Ok(Some(java)) = dao::get_java_by_id(java_id).await + { + return Ok(Some(java)); + } // Check if the version info has a suggested version let Some(supported_ver) = &info.java_version else { @@ -136,15 +157,8 @@ pub async fn get_recommended_java( dao::get_latest_java_by_major(supported_ver.major_version).await } -pub async fn prepare_java( - major: u32, -) -> LauncherResult { - let id = init_ingress( - IngressType::JavaPrepare, - "preparing java", - 100.0, - ) - .await?; +pub async fn prepare_java(major: u32) -> LauncherResult { + let id = init_ingress(IngressType::JavaPrepare, "preparing java", 100.0).await?; let java = dao::get_latest_java_by_major(major).await?; if let Some(java) = &java { @@ -165,9 +179,17 @@ pub async fn prepare_java( } } - tracing::warn!("no java installations found on the system, please install java manually or use the launcher to download it"); + tracing::warn!("no java installations found on the system, attempting to download java {}", major); - // TODO: java runtime not found - should prob download + let java_path = install_java_from_major(major).await?; + let java_info = check_java_runtime(&java_path, false).await?; + + dao::insert_java(java_path, java_info).await?; + + if let Some(java) = dao::get_latest_java_by_major(major).await? { + send_ingress(&id, 100.0).await?; + return Ok(java); + } Err(JavaError::MissingJava.into()) } @@ -206,57 +228,56 @@ pub async fn locate_java() -> LauncherResult> { #[cfg(target_os = "windows")] fn internal_locate_java() -> Vec { - let mut java_homes = Vec::new(); - - // epic common paths - let common_paths = [ - r"C:\Program Files\Java", - r"C:\Program Files (x86)\Java", - r"C:\Program Files\OpenJDK", - r"C:\Program Files\Eclipse Adoptium", - r"C:\Program Files\Zulu", - r"C:\Program Files\Amazon Corretto", - ]; - - for base_path in common_paths { - if let Ok(entries) = std::fs::read_dir(base_path) { - for entry in entries.flatten() { - if entry.file_type().map_or(false, |ft| ft.is_dir()) { - let java_exe = entry.path().join("bin").join("java.exe"); + let mut java_homes = Vec::new(); + + // epic common paths + let common_paths = [ + r"C:\Program Files\Java", + r"C:\Program Files (x86)\Java", + r"C:\Program Files\OpenJDK", + r"C:\Program Files\Eclipse Adoptium", + r"C:\Program Files\Zulu", + r"C:\Program Files\Amazon Corretto", + ]; + + for base_path in common_paths { + if let Ok(entries) = std::fs::read_dir(base_path) { + for entry in entries.flatten() { + if entry.file_type().map_or(false, |ft| ft.is_dir()) { + let java_exe = entry.path().join("bin").join("java.exe"); if java_exe.exists() { - java_homes.push(java_exe); - } - } - } - } - } - - // env vars - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let path_buf = PathBuf::from(java_home); - if path_buf.join("bin").join("java.exe").exists() { - java_homes.push(path_buf); - } - } + java_homes.push(java_exe); + } + } + } + } + } + + // env vars + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let path_buf = PathBuf::from(java_home); + if path_buf.join("bin").join("java.exe").exists() { + java_homes.push(path_buf); + } + } if let Ok(path) = std::env::var("PATH") { - for path_entry in path.split(';') { - let java_exe = PathBuf::from(path_entry).join("java.exe"); - if java_exe.exists() { - - if let Some(bin_dir) = java_exe.parent() { - if let Some(java_home) = bin_dir.parent() { - let java_home_path = java_home.to_path_buf(); - if !java_homes.contains(&java_home_path) { - java_homes.push(java_home_path); - } - } - } - } - } - } - - java_homes + for path_entry in path.split(';') { + let java_exe = PathBuf::from(path_entry).join("java.exe"); + if java_exe.exists() { + if let Some(bin_dir) = java_exe.parent() { + if let Some(java_home) = bin_dir.parent() { + let java_home_path = java_home.to_path_buf(); + if !java_homes.contains(&java_home_path) { + java_homes.push(java_home_path); + } + } + } + } + } + } + + java_homes } #[cfg(target_os = "macos")] @@ -314,3 +335,101 @@ fn internal_locate_java() -> Vec { found } + +pub async fn get_zulu_packages() -> LauncherResult> { + fetch_json::>( + reqwest::Method::GET, + format!( + "https://api.azul.com/metadata/v1/zulu/packages/?os={}&arch={}&archive_type=zip&java_package_type=jre&javafx_bundled=false&latest=true&release_status=ga&availability_types=CA&certifications=tck&page=1&page_size=100", + std::env::consts::OS, + std::env::consts::ARCH + ).as_str(), + None, + None, + ) + .await +} + +pub async fn install_java_from_major(version: u32) -> LauncherResult { + let packages = get_zulu_packages().await?; + + let package = packages.into_iter() + .find(|p| p.java_version.contains(&version)) + .ok_or_else(|| anyhow::anyhow!( + "Could not find a java package for version {}", + version + ))?; + + install_java_package(package).await +} + +pub async fn install_java_package(package: JavaPackage) -> LauncherResult { + const INGRESS_TOTAL: f64 = 100.0; + const INGRESS_TASKS: f64 = 4.0; + const INGRESS_STEP: f64 = INGRESS_TOTAL / INGRESS_TASKS; + + let version = package.download_url; + + let ingress_id = + init_ingress(IngressType::JavaPrepare, "preparing java", INGRESS_TOTAL).await?; + + let file = fetch_advanced( + reqwest::Method::GET, + &version, + None, + None, + None, + Some(&SubIngress::new(&ingress_id, INGRESS_STEP)), + ) + .await?; + + let java_dir = Dirs::get_java_dir().await?; + let archive = ZipFileReader::new(file.to_vec()) + .await + .map_err(IOError::AsyncZipError)?; + + if let Some(entry) = archive.file().entries().first() { + if let Some(dir) = entry + .filename() + .clone() + .into_string() + .unwrap() + .split('/') + .next() + { + let path = java_dir.join(dir); + if path.exists() { + io::remove_dir_all(&path).await?; + } + } + } + + io::unzip_bytes(archive.data().to_vec(), &java_dir).await?; + + let mut base_path = java_dir.join( + package + .name + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + ); + + #[cfg(target_os = "macos")] + { + let java_version = package.java_version.iter().map(|v| v.to_string()).collect::>().join("."); + base_path = base_path + .join(format!("zulu-{java_version}.jre")) + .join("Contents") + .join("Home") + .join("bin") + .join("java"); + } + + #[cfg(not(target_os = "macos"))] + { + base_path = base_path.join("bin").join(crate::constants::JAVA_BIN); + } + + Ok(base_path) +} diff --git a/packages/core/src/api/packages/data.rs b/packages/core/src/api/packages/data.rs index c50c5cb0..d046ea63 100644 --- a/packages/core/src/api/packages/data.rs +++ b/packages/core/src/api/packages/data.rs @@ -26,16 +26,52 @@ pub enum Sort { Updated } +impl std::fmt::Display for Sort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", match self { + Self::Relevance => "relevance", + Self::Downloads => "downloads", + Self::Newest => "newest", + Self::Updated => "updated", + }) + } +} + #[onelauncher_macro::specta] #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SearchQuery { pub query: Option, pub offset: Option, pub limit: Option, - pub sort: Option, + pub sort: Option, pub filters: Option, } +#[onelauncher_macro::specta] +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SearchResult { + pub project_id: String, + pub project_type: String, + pub slug: String, + pub author: String, + pub title: String, + pub description: String, + pub categories: Vec, + pub display_categories: Vec, + pub versions: Vec, + pub downloads: usize, + pub icon_url: String, + pub date_created: DateTime, + pub date_modified: DateTime, + pub latest_version: String, + pub license: String, + pub client_side: PackageSide, + pub server_side: PackageSide, + /// List of URLs to images + pub gallery: Vec, + // pub color: i64, +} + #[onelauncher_macro::specta] #[serde_as] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/packages/core/src/api/packages/provider/mod.rs b/packages/core/src/api/packages/provider/mod.rs index fdd99058..375fea85 100644 --- a/packages/core/src/api/packages/provider/mod.rs +++ b/packages/core/src/api/packages/provider/mod.rs @@ -1,7 +1,7 @@ use onelauncher_entity::loader::GameLoader; use onelauncher_entity::package::Provider; -use crate::error::LauncherResult; +use crate::{api::packages::data::SearchResult, error::LauncherResult}; use crate::utils::pagination::Paginated; use super::data::{ManagedPackage, ManagedUser, ManagedVersion, PackageAuthor, SearchQuery}; @@ -10,9 +10,9 @@ mod modrinth; pub use modrinth::ModrinthProviderImpl; -#[async_trait::async_trait(?Send)] +#[async_trait::async_trait] pub trait ProviderExt { - async fn search(&self, query: &SearchQuery) -> LauncherResult>; + async fn search(&self, query: &SearchQuery) -> LauncherResult>; async fn get(&self, slug: &str) -> LauncherResult; async fn get_multiple(&self, slugs: &[String]) -> LauncherResult>; async fn get_versions_by_hashes( @@ -42,9 +42,9 @@ pub trait ProviderExt { async fn get_versions(&self, slugs: &[String]) -> LauncherResult>; } -#[async_trait::async_trait(?Send)] +#[async_trait::async_trait] impl ProviderExt for Provider { - async fn search(&self, query: &SearchQuery) -> LauncherResult> { + async fn search(&self, query: &SearchQuery) -> LauncherResult> { match self { Self::Modrinth => ModrinthProviderImpl.search(query).await, _ => todo!("unimplemented provider"), diff --git a/packages/core/src/api/packages/provider/modrinth.rs b/packages/core/src/api/packages/provider/modrinth.rs index 3e1468fc..2712b28c 100644 --- a/packages/core/src/api/packages/provider/modrinth.rs +++ b/packages/core/src/api/packages/provider/modrinth.rs @@ -1,8 +1,6 @@ use super::ProviderExt; use crate::api::packages::data::{ - ManagedPackage, ManagedUser, ManagedVersion, ManagedVersionDependency, ManagedVersionFile, - PackageAuthor, PackageDependencyType, PackageDonationUrl, PackageLicense, PackageLinks, - PackageReleaseType, PackageSide, PackageStatus, SearchQuery, DEFAULT_LIMIT, + ManagedPackage, ManagedUser, ManagedVersion, ManagedVersionDependency, ManagedVersionFile, PackageAuthor, PackageDependencyType, PackageDonationUrl, PackageLicense, PackageLinks, PackageReleaseType, PackageSide, PackageStatus, SearchQuery, SearchResult, DEFAULT_LIMIT }; use crate::error::LauncherResult; use crate::utils::http; @@ -37,16 +35,71 @@ impl ModrinthProviderImpl { } } -#[async_trait::async_trait(?Send)] +#[async_trait::async_trait] impl ProviderExt for ModrinthProviderImpl { - async fn search(&self, query: &SearchQuery) -> LauncherResult> { + async fn search(&self, query: &SearchQuery) -> LauncherResult> { let mut url = Url::parse(url!("/search"))?; - let mut params = url.query_pairs_mut(); - params.append_pair("limit", &query.limit.unwrap_or(DEFAULT_LIMIT).to_string()); - params.append_pair("offset", &query.offset.unwrap_or(0).to_string()); + { + let mut params = url.query_pairs_mut(); - todo!() + params.append_pair("limit", &query.limit.unwrap_or(DEFAULT_LIMIT).to_string()); + params.append_pair("offset", &query.offset.unwrap_or(0).to_string()); + params.append_pair("query", query.query.clone().unwrap_or_default().as_ref()); + + if let Some(filters) = &query.filters { + let mut builder = FacetBuilder::builder(); + + if let Some(categories) = &filters.categories { + for category in categories { + builder.and(Facet("categories".to_string(), FacetOperation::Eq, category.to_string())); + } + } + + if let Some(mc_versions) = &filters.game_versions { + for mc_version in mc_versions { + builder.and(Facet("versions".to_string(), FacetOperation::Eq, mc_version.to_string())); + } + } + + if let Some(package_types) = &filters.package_types { + for package_type in package_types { + builder.and(Facet("project_type".to_string(), FacetOperation::Eq, package_type_to_string(package_type))); + } + } + + if let Some(loaders) = &filters.loaders { + for loader in loaders { + builder.and(Facet("loaders".to_string(), FacetOperation::Eq, loader.to_string())); + } + } + + params.append_pair("facets", &builder.build()); + } + + if let Some(sort) = &query.sort { + params.append_pair("index", &sort.to_string()); + } + } + + let url = url.as_str(); + + #[derive(Deserialize)] + struct SearchResults { + pub hits: Vec, + pub offset: usize, + pub limit: usize, + pub total_hits: usize, + } + + let response = http::fetch_json::(Method::GET, url, None, None).await?; + + Ok(Paginated { + offset: response.offset, + limit: response.limit, + total: response.total_hits, + items: response.hits, + }) } async fn get(&self, slug: &str) -> LauncherResult { @@ -250,6 +303,17 @@ impl ProviderExt for ModrinthProviderImpl { } } +fn package_type_to_string(package_type: &PackageType) -> String { + match package_type { + PackageType::Mod => "mod", + PackageType::ResourcePack => "resourcepack", + PackageType::Shader => "shader", + PackageType::DataPack => "datapack", + PackageType::ModPack => "modpack", + } + .to_string() +} + #[serde_as] #[derive(Deserialize)] struct ModrinthPackage { @@ -471,3 +535,77 @@ impl From for ManagedUser { } } } + +#[allow(dead_code)] +enum FacetOperation { + NotEq, + LargeEq, + Large, + SmallEq, + Small, + Eq, +} + +impl std::fmt::Display for FacetOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::NotEq => "!=", + Self::LargeEq => ">=", + Self::Large => ">", + Self::SmallEq => "<=", + Self::Small => "<", + Self::Eq => "=", + }) + } +} + +struct Facet(pub String, pub FacetOperation, pub String); + +impl std::fmt::Display for Facet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(format!("{}{}{}", self.0, self.1, self.2).as_str()) + } +} + +struct FacetBuilder { + pub facets: Vec>, +} + +impl FacetBuilder { + pub const fn builder() -> Self { + Self { facets: vec![] } + } + + pub fn and(&mut self, facet: Facet) -> &Self { + self.facets.push(vec![facet]); + self + } + + #[allow(dead_code)] + pub fn or(&mut self, facet: Facet) -> &Self { + let mut last_facet = self.facets.pop().unwrap_or_default(); + last_facet.push(facet); + self.facets.push(last_facet); + self + } + + pub fn build(&self) -> String { + let mut builder: Vec = vec![]; + + for facet in &self.facets { + let mut stringified = String::new(); + stringified.push('['); + for (i, f) in facet.iter().enumerate() { + if i != 0 { + stringified.push(','); + } + stringified.push_str(format!("\"{f}\"").as_str()); + } + stringified.push(']'); + + builder.push(stringified); + } + + builder.join(",") + } +} diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 6959941e..2ac6b8a4 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -1,8 +1,10 @@ use interpulse::api::minecraft::Version; -use onelauncher_entity::{icon::Icon, loader::GameLoader}; +use onelauncher_entity::{icon::Icon, loader::GameLoader, package::Provider, packages, resolution::Resolution}; +use sea_orm::ActiveValue::Set; +use crate::{api::packages::{data::{SearchResult, ManagedPackage, ManagedUser, ManagedVersion, SearchQuery}, provider::ProviderExt}, store::{Settings, State}, utils::pagination::Paginated}; use tauri::{AppHandle, Runtime}; -use crate::{api::{self, cluster::dao::ClusterId}, error::LauncherResult, store::{credentials::MinecraftCredentials, Core}}; +use crate::{api::{self, cluster::dao::ClusterId}, error::{LauncherError, LauncherResult}, store::{credentials::MinecraftCredentials, Core}}; use onelauncher_entity::prelude::model::*; @@ -25,8 +27,14 @@ pub trait TauriLauncherApi { #[taurpc(alias = "launchCluster")] async fn launch_cluster(id: ClusterId, uuid: Option) -> LauncherResult<()>; - // #[taurpc(alias = "updateClusterById")] - // async fn update_cluster_by_id(id: ClusterId, request: Cluster) -> LauncherResult<()>; + #[taurpc(alias = "updateClusterById")] + async fn update_cluster_by_id(id: ClusterId, request: ClusterUpdate) -> LauncherResult<()>; + + #[taurpc(alias = "getScreenshots")] + async fn get_screenshots(id: ClusterId) -> LauncherResult>; + + #[taurpc(alias = "getWorlds")] + async fn get_worlds(id: ClusterId) -> LauncherResult>; // Setting Profiles @@ -36,6 +44,9 @@ pub trait TauriLauncherApi { #[taurpc(alias = "getGlobalProfile")] async fn get_global_profile() -> SettingsProfile; + #[taurpc(alias = "updateClusterProfile")] + async fn update_cluster_profile(name: String, profile: ProfileUpdate) -> LauncherResult; + // Game Metadata #[taurpc(alias = "getGameVersions")] async fn get_game_versions() -> LauncherResult>; @@ -62,6 +73,32 @@ pub trait TauriLauncherApi { #[taurpc(alias = "openMsaLogin")] async fn open_msa_login(app_handle: AppHandle) -> LauncherResult>; + + // Settings + #[taurpc(alias = "readSettings")] + async fn read_settings() -> LauncherResult; + + #[taurpc(alias = "writeSettings")] + async fn write_settings(setting: Settings) -> LauncherResult<()>; + + // Packages + #[taurpc(alias = "searchPackages")] + async fn search_packages(provider: Provider, query: SearchQuery) -> LauncherResult>; + + #[taurpc(alias = "getPackage")] + async fn get_package(provider: Provider, slug: String) -> LauncherResult; + + #[taurpc(alias = "getMultiplePackages")] + async fn get_multiple_packages(provider: Provider, slugs: Vec) -> LauncherResult>; + + #[taurpc(alias = "getPackageVersions")] + async fn get_package_versions(provider: Provider, slug: String, mc_versions: Option>, loaders: Option>, offset: usize, limit: usize) -> LauncherResult>; + + #[taurpc(alias = "getPackageUser")] + async fn get_package_user(provider: Provider, slug: String) -> LauncherResult; + + #[taurpc(alias = "downloadPackage")] + async fn download_package(provider: Provider, package_id: String, version_id: String, cluster_id: ClusterId, skip_compatibility: Option) -> LauncherResult; } @@ -74,6 +111,23 @@ pub struct CreateCluster { icon: Option, } +#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)] +pub struct ClusterUpdate { + name: Option, + icon_url: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)] +pub struct ProfileUpdate { + pub res: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, +} #[taurpc::ipc_type] pub struct TauriLauncherApiImpl; @@ -97,6 +151,22 @@ impl TauriLauncherApi for TauriLauncherApiImpl { async fn create_cluster(self, options: CreateCluster) -> LauncherResult { let cluster = api::cluster::create_cluster(&options.name, &options.mc_version, options.mc_loader, options.mc_loader_version.as_deref(), options.icon).await?; + api::setting_profiles::create_profile(&options.name, |mut active_model: SettingsProfilePartial| async move { + active_model.force_fullscreen = Set(Some(false)); + + active_model.mem_max = Set(Some(2048)); + + Ok(active_model) + }) + .await?; + + api::cluster::dao::update_cluster_by_id(cluster.id, |mut active_model: ClusterPartial| async move { + active_model.setting_profile_name = Set(Some(options.name.clone())); + + Ok(active_model) + }) + .await?; + Ok(cluster) } @@ -119,22 +189,38 @@ impl TauriLauncherApi for TauriLauncherApiImpl { Ok(()) } - // async fn update_cluster_by_id(self, id: ClusterId, request: clusters::ActiveModel) -> LauncherResult<()> { - // // let updated_cluster = api::cluster::dao::update_cluster_by_id(id, |mut active_model| async move { - // // if let Some(name) = request.name { - // // active_model.name = Set(name); - // // } + async fn update_cluster_by_id(self, id: ClusterId, request: ClusterUpdate) -> LauncherResult<()> { + api::cluster::dao::update_cluster_by_id(id, |mut active_model: ClusterPartial| async move { + if let Some(name) = request.name { + active_model.name = Set(name) + } - // // active_model.updated_at = Set(chrono::Utc::now().naive_utc()); + // ok i know this is so wrong but im not a rust guy + if let Some(icon_url) = request.icon_url { + api::cluster::dao::set_icon_by_id(id, &icon_url).await?; + } - // // Ok(active_model) - // // }) - // // .await - // // .map_err(|e| e.to_string())?; + Ok(active_model) + }) + .await + .map_err(|e| LauncherError::from(e))?; - // Ok(()) - // } + Ok(()) + } + async fn get_screenshots(self, id: ClusterId) -> LauncherResult> { + let cluster = api::cluster::dao::get_cluster_by_id(id).await? + .ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", id))?; + + api::cluster::content::get_screenshots(&cluster).await + } + + async fn get_worlds(self, id: ClusterId) -> LauncherResult> { + let cluster = api::cluster::dao::get_cluster_by_id(id).await? + .ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", id))?; + + api::cluster::content::get_worlds(&cluster).await + } // Setting Profiles async fn get_global_profile(self) -> SettingsProfile { @@ -145,6 +231,47 @@ impl TauriLauncherApi for TauriLauncherApiImpl { api::setting_profiles::dao::get_profile_or_default(name.as_ref()).await } + // please kill this with fire + async fn update_cluster_profile(self, name: String, profile: ProfileUpdate) -> LauncherResult { + let profile = api::setting_profiles::dao::update_profile_by_name(&name, |mut active_model: SettingsProfilePartial| async move { + if let Some(res) = profile.res { + active_model.res = Set(Some(res)); + } + + if let Some(force_fullscreen) = profile.force_fullscreen { + active_model.force_fullscreen = Set(Some(force_fullscreen)); + } + + if let Some(mem_max) = profile.mem_max { + active_model.mem_max = Set(Some(mem_max)); + } + + if let Some(launch_args) = profile.launch_args { + active_model.launch_args = Set(Some(launch_args)); + } + + if let Some(launch_env) = profile.launch_env { + active_model.launch_env = Set(Some(launch_env)); + } + + if let Some(hook_pre) = profile.hook_pre { + active_model.hook_pre = Set(Some(hook_pre)); + } + + if let Some(hook_wrapper) = profile.hook_wrapper { + active_model.hook_wrapper = Set(Some(hook_wrapper)); + } + + if let Some(hook_post) = profile.hook_post { + active_model.hook_post = Set(Some(hook_post)); + } + + Ok(active_model) + }) + .await?; + + Ok(profile) + } // Game Metadata async fn get_loaders_for_version(self, mc_version: String) -> LauncherResult> { @@ -242,4 +369,60 @@ impl TauriLauncherApi for TauriLauncherApiImpl { Ok(None) } + + async fn read_settings(self) -> LauncherResult { + let state = State::get().await?; + let settings = state.settings.read().await; + + Ok(settings.clone()) + } + + async fn write_settings(self, setting: Settings) -> LauncherResult<()> { + let state = State::get().await?; + let mut settings = state.settings.write().await; + + *settings = setting; + + settings.save().await?; + + Ok(()) + } + + // Packages + async fn search_packages(self, provider: Provider, query: SearchQuery) -> LauncherResult> { + Ok(provider.search(&query).await?) + } + + async fn get_package(self, provider: Provider, slug: String) -> LauncherResult { + Ok(provider.get(&slug).await?) + } + + async fn get_multiple_packages(self, provider: Provider, slugs: Vec) -> LauncherResult> { + Ok(provider.get_multiple(&slugs).await?) + } + + async fn get_package_versions(self, provider: Provider, slug: String, mc_versions: Option>, loaders: Option>, offset: usize, limit: usize) -> LauncherResult> { + Ok(provider.get_versions_paginated(&slug, mc_versions, loaders, offset, limit).await?) + } + + async fn get_package_user(self, provider: Provider, slug: String) -> LauncherResult { + Ok(provider.get_user(&slug).await?) + } + + async fn download_package(self, provider: Provider, package_id: String, version_id: String, cluster_id: ClusterId, skip_compatibility: Option) -> LauncherResult { + let cluster = api::cluster::dao::get_cluster_by_id(cluster_id).await? + .ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", cluster_id))?; + + let package = provider.get(&package_id).await?; + + let versions = provider.get_versions(&[version_id]).await?; + let version = versions.into_iter().next() + .ok_or_else(|| anyhow::anyhow!("Version not found"))?; + + let model = api::packages::download_package(&package, &version, None).await?; + + api::packages::link_package(&model, &cluster, skip_compatibility).await?; + + Ok(model) + } } diff --git a/packages/core/src/logger.rs b/packages/core/src/logger.rs index fd0b74e2..3b52511c 100644 --- a/packages/core/src/logger.rs +++ b/packages/core/src/logger.rs @@ -41,14 +41,15 @@ pub async fn start_logger() { // This will log to a file in the logs directory, and will not show any logs in the console #[cfg(not(debug_assertions))] pub async fn start_logger() { - use chrono::Local; + use chrono::Local; use std::fs::OpenOptions; use tracing_subscriber::fmt::time::ChronoLocal; - - use crate::io::Dirs; + + use crate::store::Dirs; + use crate::store::Core; // Initialize and get logs directory path - let logs_dir = Dirs::get().await?.launcher_logs_dir(); + let logs_dir = Dirs::get_launcher_logs_dir().await.unwrap_or_default(); let log_file_name = format!("launcher_{}.log", Local::now().format("%Y%m%d_%H%M%S")); let log_file_path = logs_dir.join(log_file_name); @@ -65,7 +66,7 @@ pub async fn start_logger() { Ok(file) => file, Err(e) => { eprintln!("Could not start open log file: {e}"); - return None; + return; } }; @@ -86,7 +87,7 @@ pub async fn start_logger() { .with_ansi(false) .with_timer(ChronoLocal::rfc_3339()); - if let Some(span) = Core::get().span_formatting.clone() { + if let Some(span) = Core::get().logger_span_formatting.clone() { fmt_layer = fmt_layer.with_span_events(span); } diff --git a/packages/core/src/store/settings.rs b/packages/core/src/store/settings.rs index 07cb1fad..6382f235 100644 --- a/packages/core/src/store/settings.rs +++ b/packages/core/src/store/settings.rs @@ -15,6 +15,7 @@ pub struct Settings { pub discord_enabled: bool, pub max_concurrent_requests: usize, pub settings_version: u32, + pub native_window_frame: bool } impl Default for Settings { @@ -26,6 +27,7 @@ impl Default for Settings { enable_gamemode: false, max_concurrent_requests: 25, settings_version: 1, + native_window_frame: false, } } } diff --git a/packages/core/src/utils/pagination.rs b/packages/core/src/utils/pagination.rs index 77c039e5..467aafe9 100644 --- a/packages/core/src/utils/pagination.rs +++ b/packages/core/src/utils/pagination.rs @@ -1,18 +1,18 @@ use std::fmt::Debug; - +use std::marker::Send; use serde::{Deserialize, Serialize}; #[onelauncher_macro::specta] #[derive(Clone, Serialize, Deserialize)] pub struct Paginated -where T: Clone + Serialize { +where T: Clone + Serialize + Send + Sync { pub total: usize, pub offset: usize, pub limit: usize, pub items: Vec, } -impl Debug for Paginated { +impl Debug for Paginated { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Paginated") .field("total", &self.total) @@ -23,7 +23,7 @@ impl Debug for Paginated { } } -impl Paginated { +impl Paginated { #[must_use] pub const fn new(total: usize, offset: usize, limit: usize, items: Vec) -> Self { Self { total, offset, limit, items } diff --git a/packages/entity/src/domain/icon.rs b/packages/entity/src/domain/icon.rs index 2f68713c..3c57e784 100644 --- a/packages/entity/src/domain/icon.rs +++ b/packages/entity/src/domain/icon.rs @@ -24,7 +24,7 @@ impl Icon { return None; } - Some(Self(format!("file://{}", path.display()))) + Some(Self(format!("{}", path.display()))) } pub fn try_from_url(url: url::Url) -> Option { diff --git a/packages/web_common/package.json b/packages/web_common/package.json index 18c0cf6d..0a99a4aa 100644 --- a/packages/web_common/package.json +++ b/packages/web_common/package.json @@ -42,5 +42,9 @@ "vite": "^6.1.0", "vite-plugin-dts": "^4.5.4", "vite-plugin-static-copy": "^3.0.0" + }, + "dependencies": { + "@untitled-theme/icons-react": "^0.14.0", + "overlayscrollbars-react": "^0.5.6" } } diff --git a/packages/web_common/src/components/Dropdown.tsx b/packages/web_common/src/components/Dropdown.tsx index 255fbf15..f48203e5 100644 --- a/packages/web_common/src/components/Dropdown.tsx +++ b/packages/web_common/src/components/Dropdown.tsx @@ -6,6 +6,7 @@ import type { import type { VariantProps } from 'tailwind-variants'; import type { ClassNameString } from '../types/global'; import { ChevronDownIcon, ChevronUpIcon } from '@untitled-theme/icons-react'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; import React from 'react'; import { Label as AriaLabel, @@ -36,15 +37,19 @@ const dropdownStyles = tv({ triggerCustomIcon: 'w-4 h-4', popover: [ - 'mt-1 rounded-lg shadow-md', + // i'll fix this if i dont forget + 'mt-1 w-full rounded-lg shadow-md', 'bg-component-bg border border-component-border', 'data-[entering]:animate-in data-[entering]:fade-in data-[entering]:zoom-in-95', 'data-[exiting]:animate-out data-[exiting]:fade-out data-[exiting]:zoom-out-95', + 'w-(--trigger-width)', ], popoverContentWrapper: 'flex flex-col gap-1', - listBox: 'max-h-46 overflow-auto outline-none flex flex-col gap-0.5', + listBox: 'outline-none flex flex-col gap-0.5', + + overlayScrollbars: 'max-h-46 overflow-auto', listToolRowWrapper: 'p-1 mt-1', }, @@ -135,9 +140,11 @@ export function Dropdown(props: DropdownProps) {
- - {children} - + + + {children} + + {listToolRow && (
{listToolRow} diff --git a/packages/web_common/src/components/Popup.tsx b/packages/web_common/src/components/Popup.tsx new file mode 100644 index 00000000..0523731c --- /dev/null +++ b/packages/web_common/src/components/Popup.tsx @@ -0,0 +1,22 @@ +import type { PopoverProps } from 'react-aria-components'; +import { Dialog, DialogTrigger, Popover } from 'react-aria-components'; + +interface MyPopoverProps extends Omit { + children: React.ReactNode; +} + +export function Popup({ children, ...props }: MyPopoverProps) { + return ( + + + {children} + + + ); +} + +function Trigger({ children, ...props }: any) { + return ; +} + +Popup.Trigger = Trigger; diff --git a/packages/web_common/src/components/index.ts b/packages/web_common/src/components/index.ts index 2782b304..1632a0ac 100644 --- a/packages/web_common/src/components/index.ts +++ b/packages/web_common/src/components/index.ts @@ -3,6 +3,7 @@ export * from './Button'; export * from './ContextMenu'; export * from './Dropdown'; export * from './Menu'; +export * from './Popup'; export * from './SelectList'; export * from './Show'; export * from './Switch'; diff --git a/packages/web_common/src/theme.css b/packages/web_common/src/theme.css index f0be6864..f5fd21f3 100644 --- a/packages/web_common/src/theme.css +++ b/packages/web_common/src/theme.css @@ -109,3 +109,14 @@ body { .os-hide-horizontal-scrollbar > .os-scrollbar-horizontal { display: none; } + +h1 { @apply text-[2rem] font-semibold; line-height: 1.5 !important; } +h2 { @apply text-[1.5rem] font-semibold; line-height: 1.25 !important; } +h3 { @apply text-[1.25rem] font-medium; } +h4 { @apply text-[1rem] font-medium; } +h5 { @apply text-[0.875rem] text-fg-secondary font-bold uppercase; } +h6 { @apply text-[0.75rem] text-fg-secondary font-medium uppercase; } + +code { + @apply rounded-md p-2 text-left; +} \ No newline at end of file diff --git a/packages/web_common/vite.config.ts b/packages/web_common/vite.config.ts index c4962b2c..1389794e 100644 --- a/packages/web_common/vite.config.ts +++ b/packages/web_common/vite.config.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig, normalizePath } from 'vite'; import dts from 'vite-plugin-dts'; import { viteStaticCopy } from 'vite-plugin-static-copy'; @@ -12,7 +12,7 @@ export default defineConfig({ viteStaticCopy({ targets: [ { - src: path.resolve(__dirname, 'src/theme.css'), + src: normalizePath(path.resolve(__dirname, 'src/theme.css')), dest: './', }, ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fa3da5a..41bca0d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.2.1 version: 2.2.1 + '@tauri-apps/plugin-opener': + specifier: ^2.3.0 + version: 2.3.0 '@tauri-apps/plugin-shell': specifier: ^2.2.1 version: 2.2.1 @@ -291,6 +294,9 @@ importers: taurpc: specifier: ^1.8.1 version: 1.8.1 + valtio: + specifier: ^2.1.5 + version: 2.1.5(@types/react@19.1.2)(react@19.1.0) devDependencies: '@tanstack/eslint-config': specifier: ^0.1.0 @@ -365,6 +371,9 @@ importers: motion: specifier: ^12.10.5 version: 12.10.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + overlayscrollbars-react: + specifier: ^0.5.6 + version: 0.5.6(overlayscrollbars@2.11.2)(react@19.1.0) react: specifier: ^19.0.0 version: 19.1.0 @@ -2814,6 +2823,9 @@ packages: '@tauri-apps/plugin-dialog@2.2.1': resolution: {integrity: sha512-wZmCouo4PgTosh/UoejPw9DPs6RllS5Pp3fuOV2JobCu36mR5AXU2MzU9NZiVaFi/5Zfc8RN0IhcZHnksJ1o8A==} + '@tauri-apps/plugin-opener@2.3.0': + resolution: {integrity: sha512-yAbauwp8BCHIhhA48NN8rEf6OtfZBPCgTOCa10gmtoVCpmic5Bq+1Ba7C+NZOjogedkSiV7hAotjYnnbUVmYrw==} + '@tauri-apps/plugin-shell@2.2.1': resolution: {integrity: sha512-G1GFYyWe/KlCsymuLiNImUgC8zGY0tI0Y3p8JgBCWduR5IEXlIJS+JuG1qtveitwYXlfJrsExt3enhv5l2/yhA==} @@ -4905,6 +4917,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5489,6 +5504,18 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + valtio@2.1.5: + resolution: {integrity: sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + vite-node@2.1.3: resolution: {integrity: sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -8521,6 +8548,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.5.0 + '@tauri-apps/plugin-opener@2.3.0': + dependencies: + '@tauri-apps/api': 2.5.0 + '@tauri-apps/plugin-shell@2.2.1': dependencies: '@tauri-apps/api': 2.5.0 @@ -10956,6 +10987,8 @@ snapshots: process@0.11.10: {} + proxy-compare@3.0.1: {} + punycode@2.3.1: {} quansync@0.2.10: {} @@ -11687,6 +11720,13 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + valtio@2.1.5(@types/react@19.1.2)(react@19.1.0): + dependencies: + proxy-compare: 3.0.1 + optionalDependencies: + '@types/react': 19.1.2 + react: 19.1.0 + vite-node@2.1.3(@types/node@22.7.9)(lightningcss@1.30.1)(sass@1.80.3): dependencies: cac: 6.7.14