Merge branch 'refactor/frontend' into oneclient/main

This commit is contained in:
LynithDev 2025-07-17 19:53:12 +02:00
commit c3a851d77e
63 changed files with 2737 additions and 431 deletions

60
Cargo.lock generated
View file

@ -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"

View file

@ -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" ] }

View file

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

View file

@ -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"
}
]
}
]
}

View file

@ -6,6 +6,8 @@ pub trait OneLauncherApi {
async fn return_error() -> LauncherResult<()>;
async fn open_dev_tools<R: Runtime>(webview_window: tauri::WebviewWindow<R>);
async fn set_window_style<R: Runtime>(webview_window: tauri::WebviewWindow<R>, decorations: bool);
}
#[taurpc::ipc_type]
@ -24,4 +26,7 @@ impl OneLauncherApi for OneLauncherApiImpl {
webview_window.open_devtools();
}
async fn set_window_style<R: Runtime>(self, webview_window: tauri::WebviewWindow<R>, decorations: bool) {
webview_window.set_decorations(decorations).ok();
}
}

View file

@ -54,7 +54,8 @@ async fn initialize_tauri(builder: tauri::Builder<tauri::Wry>) -> 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<tauri::Wry>) -> 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<dyn std::error::Err
.get_webview_window("main")
.ok_or_else(|| anyhow::anyhow!("no window called main was found"))?;
// tokio::task::spawn(async move {
// // let state = State::get().await.expect("failed to get state");
// // let settings = state.settings.read().await;
// // win.set_decorations(settings.);
// });
// Initialize window decorations based on settings
let win_clone = win.clone();
tokio::task::spawn(async move {
if let Ok(state) = State::get().await {
let settings = state.settings.read().await;
// native_window_frame=true means use native decorations
// native_window_frame=false means use custom frame (no decorations)
win_clone.set_decorations(settings.native_window_frame).ok();
}
});
win.show()?;

View file

@ -24,6 +24,7 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.2.1",
"@untitled-theme/icons-react": "^0.14.0",
"motion": "^12.10.5",
@ -37,7 +38,8 @@
"tailwindcss": "^4.0.6",
"tailwindcss-animated": "^2.0.0",
"tailwindcss-react-aria-components": "^2.0.0",
"taurpc": "^1.8.1"
"taurpc": "^1.8.1",
"valtio": "^2.1.5"
},
"devDependencies": {
"@tanstack/eslint-config": "^0.1.0",

View file

@ -1,16 +1,19 @@
import type { ToOptions } from '@tanstack/react-router';
import useNotifications from '@/hooks/useNotification';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Button, Menu } from '@onelauncher/common/components';
import { Button, Popup } from '@onelauncher/common/components';
import { Link } from '@tanstack/react-router';
import { Bell01Icon, Cloud01Icon, TerminalBrowserIcon } from '@untitled-theme/icons-react';
import { MenuTrigger } from 'react-aria-components';
import OneLauncherText from '../assets/logos/onelauncher_text.svg';
import PlayerHead from './content/PlayerHead';
import AccountPopup from './overlay/popups/AccountPopup';
import NotificationPopup from './overlay/popups/NotificationPopup';
function Navbar() {
const defaultUser = useCommand('getDefaultUser', () => bindings.core.getDefaultUser(false));
const { list } = useNotifications();
const notificationCount = Object.keys(list).length;
return (
<div className="h-15 min-h-15 flex flex-row items-center *:flex-1">
@ -36,36 +39,31 @@ function Navbar() {
{/* Notification Manager Button */}
<div className="relative">
<MenuTrigger>
<Button color="ghost" size="icon">
<Popup.Trigger>
<Button className="relative" color="ghost" size="icon">
<Bell01Icon />
{notificationCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
{notificationCount > 99 ? '99+' : notificationCount}
</span>
)}
</Button>
<Menu.Popover placement="bottom right">
<Menu>
<Menu.Item>Hello World</Menu.Item>
</Menu>
</Menu.Popover>
</MenuTrigger>
{/* <NotificationPopup
className="mt-2"
mount={notificationButtonContainer}
ref={el => el.classList.add('right-0')}
setVisible={setNotificationMenuOpen}
visible={notificationMenuOpen}
/> */}
<Popup>
<NotificationPopup />
</Popup>
</Popup.Trigger>
</div>
<MenuTrigger>
<Popup.Trigger>
<Button className="p-0" color="ghost" size="icon">
<PlayerHead className="h-full rounded-md hover:opacity-70" uuid={defaultUser.data?.id} />
</Button>
<Menu.Popover>
<Popup>
<AccountPopup />
</Menu.Popover>
</MenuTrigger>
</Popup>
</Popup.Trigger>
</div>
</div>
);

View file

@ -21,7 +21,7 @@ function SettingsRow({ disabled, description, icon, title, children }: SettingsR
</div>
<div className="flex flex-1 flex-col gap-2">
<h3 className="text-lg capitalize leading-tight">{title}</h3>
<p className="text-lg capitalize leading-tight">{title}</p>
<p className="text-sm text-fg-secondary">{description}</p>
</div>

View file

@ -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 (
<div className="z-[99999] w-screen flex flex-row items-center justify-between gap-0.5 bg-window-frame" data-tauri-drag-region>
<div className="flex-1 flex flex-row items-center pointer-events-none">
<TitlebarButton icon={ChevronLeftIcon} onClick={back} />
</div>
<Show when={settings?.native_window_frame === false}>
<div className="z-[99999] w-screen flex flex-row items-center justify-between gap-0.5 bg-window-frame" data-tauri-drag-region>
<div className="flex-1 flex flex-row items-center pointer-events-none">
<TitlebarButton icon={ChevronLeftIcon} onClick={back} />
</div>
<div className="flex-1 flex flex-row items-center justify-end pointer-events-none">
<TitlebarButton icon={MinusIcon} onClick={minimize} />
<TitlebarButton icon={isMaximized ? Minimize01Icon : Maximize02Icon} onClick={toggleMaximize} />
<TitlebarButton danger icon={XCloseIcon} onClick={quit} />
<div className="flex-1 flex flex-row items-center justify-end pointer-events-none">
<TitlebarButton icon={MinusIcon} onClick={minimize} />
<TitlebarButton icon={isMaximized ? Minimize01Icon : Maximize02Icon} onClick={toggleMaximize} />
<TitlebarButton danger icon={XCloseIcon} onClick={quit} />
</div>
</div>
</div>
</Show>
);
}

View file

@ -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<string, string> = {
modrinth: ModrinthIcon,
curseforge: CurseforgeIcon,
skyclient: SkyClientImage,
};
return mapping[providerName];
}
type ProviderIconProp = ImgHTMLAttributes<HTMLImageElement> & RefAttributes<HTMLImageElement> & {
provider: string | undefined;
};
function ProviderIcon({
provider,
src: _src,
alt: _alt,
...rest
}: ProviderIconProp) {
return (
<img
{...rest}
alt={`${provider}'s logo`}
src={provider ? getProviderLogoSrc(provider) : VanillaImage}
/>
);
}
export default ProviderIcon;

View file

@ -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<string>('');
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 (
<img
{...props}
alt={`${world_name} icon`}
className={twMerge('rounded-md size-16', className)}
onError={(e) => {
e.currentTarget.src = DefaultWorld;
}}
src={imagePath}
/>
);
}

View file

@ -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<GameLoader, string> = {

View file

@ -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<ClusterFormData>({
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 = (
<ClusterLoader
onSelectLoader={loader => setFormData(prev => ({ ...prev, loader }))}
selectedLoader={formData.loader}
/>
<ClusterLoader onSelectProvider={provider => setFormData(prev => ({ ...prev, clusterProvider: provider }))} selectedProvider={formData.clusterProvider} />
);
break;
}
@ -89,8 +91,10 @@ export function NewClusterCreate() {
case 'info': {
stepContent = (
<ClusterInformation
clusterLoader={formData.loader}
clusterName={formData.clusterName}
clusterVersion={formData.clusterVersion}
onClusterLoaderChange={loader => 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 (
<>
<div className="grid grid-cols-3 gap-2">
<ProviderCard
icon={<User03Icon />}
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={<LauncherIcon launcher={data} />}
key={i}
name={data}
selected={selectedLoader === data}
setSelected={() => onSelectLoader(data)}
selected={selectedProvider === data}
setSelected={() => onSelectProvider(data)}
/>
))}
</div>
@ -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={<TextInputIcon className="size-4" />}
onChange={e => onClusterNameChange(e.target.value)}
placeholder="Name"
required
value={clusterName}
/>
</Option>
@ -258,35 +267,16 @@ function ClusterInformation({
</Option>
<Option header="Loader">
<Dropdown className="w-full" defaultSelectedKey="vanilla">
<Dropdown.Item>
<div className="flex flex-row">
<LoaderIcon className="size-5" loader="vanilla" />
{' '}
Vanilla
</div>
</Dropdown.Item>
<Dropdown.Item>
<div className="flex flex-row">
<LoaderIcon className="size-5" loader="fabric" />
{' '}
Fabric
</div>
</Dropdown.Item>
<Dropdown.Item>
<div className="flex flex-row">
<LoaderIcon className="size-5" loader="quilt" />
{' '}
Quilt
</div>
</Dropdown.Item>
<Dropdown.Item>
<div className="flex flex-row">
<LoaderIcon className="size-5" loader="forge" />
{' '}
Forge
</div>
</Dropdown.Item>
{/* TODO: fixme */}
<Dropdown defaultSelectedKey="vanilla" onSelectionChange={e => onClusterLoaderChange(e.toString() as GameLoader)} selectedKey={clusterLoader}>
{LOADERS.map(loader => (
<Dropdown.Item id={loader} key={loader}>
<div className="flex flex-row gap-2">
<LoaderIcon className="size-5" loader={loader as GameLoader} />
{upperFirst(loader)}
</div>
</Dropdown.Item>
))}
</Dropdown>
</Option>
</div>

View file

@ -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 (
<ModalOverlay className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" isDismissable>
<AriaDialog className="" {...props} />
<AriaModal>
<AriaDialog className="" {...props} />
</AriaModal>
</ModalOverlay>
);
}

View file

@ -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 (
<div className={twMerge('flex flex-col gap-y-1 p-2 bg-component-bg border border-component-border rounded-lg shadow-lg', isVisible && !isRemoving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0')}>
<div className="grid grid-cols-[24px_1fr_auto] min-h-10 place-items-center gap-3">
<InfoCircleIcon className="h-6 w-6 text-fg-primary" />
<div className="w-full flex flex-col gap-y-1">
<span className="text-fg-primary font-medium capitalize">{notification.title}</span>
<span className="text-sm text-fg-secondary/60 capitalize">{notification.message}</span>
</div>
<Button
className="flex-shrink-0 h-6 w-6 p-0"
color="ghost"
onClick={handleRemove}
size="icon"
>
<XIcon className="h-3 w-3" />
</Button>
</div>
<Show when={notification.fraction !== undefined && !fractionEnded(notification.fraction)}>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-brand-disabled/10">
<div
className="h-full max-w-full min-w-0 rounded-full bg-brand transition-width"
style={{
width: `${Math.floor(notification.fraction! * 100)}%`,
}}
/>
</div>
</Show>
</div>
);
}
function NotificationToasts() {
const { list } = useNotifications();
const [toasts, setToasts] = useState<Array<ToastNotification>>([]);
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 (
<div className="fixed bottom-4 right-4 z-50 pointer-events-none">
<div className="pointer-events-auto">
{toasts.map(toast => (
<NotificationToast
key={toast.id}
notification={toast}
onRemove={removeToast}
/>
))}
</div>
</div>
);
}
export default NotificationToasts;

View file

@ -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 (
<Menu className="min-w-3xs">
<div className="min-w-3xs">
{defaultUser.data && (
<Menu.Item>
<div>
<AccountEntry
loggedIn
onClick={() => {}}
onClick={() => { }}
user={defaultUser.data}
/>
</Menu.Item>
</div>
)}
{users.data?.filter(user => user.id === defaultUser.data?.id).map(user => (
<Menu.Item key={user.id}>
{users.data?.filter(user => user.id !== defaultUser.data?.id).map(user => (
<div key={user.id}>
<AccountEntry
onClick={() => setDefaultUser(user)}
user={user}
/>
</Menu.Item>
</div>
))}
{(users.data?.length ?? 0) > 0 && (
<Separator />
)}
<Menu.Item className="flex flex-row justify-between">
<div>
<Button color="ghost">
<div className="flex flex-row justify-between">
<div className="self-center">
<Button color="ghost" onClick={() => addAccount.refetch()}>
<PlusIcon />
Add Account
</Button>
</div>
<div className="flex flex-row">
<Button color="ghost" size="iconLarge">
<Settings01Icon className="stroke-fg-primary" />
</Button>
<Link to="/app/settings/accounts">
<Button color="ghost" size="iconLarge">
<Settings01Icon className="stroke-fg-primary" />
</Button>
</Link>
</div>
</Menu.Item>
</Menu>
</div>
</div>
);
}

View file

@ -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 (
<div className="p-3 w-80 text-center">
<p className="text-fg-secondary text-sm">No notifications</p>
</div>
);
return (
<div className="min-w-80 w-80 max-w-96 max-h-96 overflow-y-auto">
<div className="flex flex-col bg-page-elevated">
<div className="flex items-center justify-between mb-3">
<h4 className="text-fg-primary ml-2">Notifications</h4>
</div>
<div className="flex flex-col gap-2 mb-3">
{notificationEntries.map(([id, data]) => (
<div className="flex items-start gap-3 p-2 rounded-lg" key={id}>
<div className="flex-shrink-0 self-center">
<InfoCircleIcon className="h-6 w-6 text-fg-primary" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-fg-primary text-sm">
{data.title}
</h4>
<p className="text-fg-secondary text-xs mt-1">
{data.message}
</p>
</div>
<div className="flex-shrink-0 mt-2 self-center">
<div className="h-2 w-2 rounded-full bg-blue-500" />
</div>
</div>
))}
</div>
<hr className="mb-2 text-component-border-pressed" />
{notificationEntries.length > 0 && (
<div className="flex items-center justify-between">
<Button
className="text-left"
color="ghost"
onClick={clear}
size="normal"
>
<Trash01Icon className="h-4 w-4" />
Clear Notifications
</Button>
{/* <Button
className="h-6 w-6 p-0"
color="ghost"
size="iconLarge"
>
<Settings01Icon />
</Button> */}
</div>
)}
</div>
</div>
);
}
export default NotificationPopup;

View file

@ -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<BrowserControllerType | null>(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<ClusterModel | undefined>(undefined);
const [provider, setProvider] = useState<Provider>(PROVIDERS[0]);
const [query, setQuery] = useState<SearchQuery>({
filters: null,
query: null,
limit: 18 as unknown as bigint,
offset: null,
sort: null,
});
const context = useMemo<BrowserControllerType>(() => ({
cluster,
setCluster,
provider,
setProvider,
query,
setQuery,
search: () => {},
}), [cluster, provider, query]);
return (
<BrowserContext.Provider value={context}>
{children}
</BrowserContext.Provider>
);
}
export function useBrowserSearch(provider: Provider, query: SearchQuery) {
return useCommand('searchPackages', () => bindings.core.searchPackages(provider, query));
}

View file

@ -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<Model | undefined>();
const [cluster, setCluster] = useState<ClusterModel | undefined>();
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<Array<ClusterModel> | undefined>();
useEffect(() => {
if (!result.data)
return;
setClusters(result.data);
}, [result.data]);
return clusters;
}

View file

@ -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<string, NotificationData>;
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<React.SetStateAction<Notifications>>;
}
const defaultContextValue: NotificationContextValue = {
notifications: {},
setNotifications: () => { },
};
const NotificationContext = createContext<NotificationContextValue>(defaultContextValue);
interface NotificationProviderProps {
children: ReactNode;
}
export const NotificationProvider = memo(({ children }: NotificationProviderProps) => {
const [notifications, setNotifications] = useState<Notifications>({});
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 (
<NotificationContext.Provider value={contextValue}>
{children}
</NotificationContext.Provider>
);
});
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;

View file

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

View file

@ -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<Settings> | null;
setSettingsToSave: (settings: Partial<Settings> | null) => void;
settingsChanged: boolean;
setSettingsChanged: (changed: boolean) => void;
createSetting: <K extends keyof Settings, V = Settings[K]>(name: K, value: V) => [V, (value: V) => void];
}
const SettingsContext = createContext<SettingsControllerType | null>(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<Partial<Settings> | null>(null);
const filteredSettingsToSave = settingsToSave
? Object.fromEntries(
Object.entries(settingsToSave),
) as Partial<Settings>
: {};
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<Settings> | null) => {
setSettingsToSave(newSettings);
setSettingsChanged(true);
}, []);
const createSetting = useCallback(<K extends keyof Settings, V = Settings[K]>(
name: K,
initialValue: V,
): [V, (value: V) => void] => {
const [value, setValue] = useState<V>(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 (
<SettingsContext.Provider value={controller}>
{children}
{settingsChanged && (
<div className="pointer-events-none absolute bottom-5 left-1/2 w-full flex flex-col items-center justify-center -translate-x-1/2">
<div className="pointer-events-auto flex flex-row gap-1 border border-component-border rounded-md bg-component-bg p-3 px-4">
You have unsaved changes!
<button
className="text-brand filter-brightness-130 hover:underline"
onClick={saveChangedSettings}
type="button"
>
Save Now
</button>
</div>
</div>
)}
</SettingsContext.Provider>
);
}
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;

View file

@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<SettingsProvider>
<NotificationProvider>
<RouterProvider router={router} />
</NotificationProvider>
</SettingsProvider>
</QueryClientProvider>
</StrictMode>,
);

View file

@ -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() {
<Outlet />
<NotificationToasts />
<TanStackRouterDevtools />
</div>
</RouterProvider>

View file

@ -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 (
<div>
<p>asdasdas</p>
<TextField />
{/* we can open the modal but we cant close it for some reason */}
<Modal.Trigger>
<Button>Open Modal</Button>
<Modal>
<p>sadsadsad</p>
</Modal>
</Modal.Trigger>
<Dropdown label="Select a version">
<Dropdown.Item>Slmalr</Dropdown.Item>
<Dropdown.Item>Slmalr 2</Dropdown.Item>
</Dropdown>
</div>
<BrowserLayout>
<div className="flex flex-col gap-8">
<Featured />
</div>
</BrowserLayout>
);
}
function Featured() {
const context = useBrowserContext();
const search = useBrowserSearch(context.provider, context.query);
return (
<Show when>
<div className="flex flex-col gap-y-1">
<h5 className="ml-2">Featured</h5>
<div className="w-full flex flex-row overflow-hidden rounded-lg bg-page-elevated">
<div className="w-full p-1">
<img alt="thumbnail" className="aspect-ratio-video h-full rounded-md object-cover object-center" src="https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp" />
</div>
<div className="max-w-84 min-w-52 flex flex-col gap-y-1 p-4">
<h2>Sodium</h2>
<Show when={false}>
<div className="w-fit flex flex-row items-center gap-x-1 rounded-lg bg-border/10 px-1.5 py-1 text-fg-primary transition hover:opacity-80">
<img alt="OneConfig Logo" className="h-3.5 w-3.5" src={OneConfigLogo} />
<span className="text-sm font-medium">OneConfig Integrated</span>
</div>
</Show>
<p className="mt-1 flex-1 leading-normal">Peak mod</p>
<div className="flex flex-row justify-end">
<Button color="ghost">
View Package
<ChevronRightIcon />
</Button>
</div>
</div>
</div>
{search.status}
{search.isSuccess && (
<>
{search.data.items.map(item => <p key={item.id}>{item.name}</p>)}
</>
)}
</div>
</Show>
);
}

View file

@ -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 (
<BrowserProvider>
<div>
<Outlet />
</div>
</BrowserProvider>
);
}
export function BrowserLayout(props: any) {
return (
<div className="relative h-full flex flex-1 flex-col items-center gap-2">
<div className="h-full w-full max-w-screen-xl flex flex-1 flex-col items-center gap-y-2">
<div className="grid grid-cols-[220px_auto_220px] w-full gap-x-6">
<div />
<BrowserToolbar />
<div />
</div>
<div className="grid grid-cols-[220px_auto_220px] w-full max-w-screen-xl gap-x-6 pb-8">
<BrowserCategories />
<div className="h-full flex flex-col gap-y-4">
<div className="h-full flex-1">
{props.children}
</div>
</div>
<BrowserSidebar />
</div>
</div>
</div>
);
}
function BrowserSidebar() {
const context = useBrowserContext();
const clusters = useClusters();
return (
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-1">
<h6 className="my-1">Active Cluster</h6>
<Dropdown
onSelectionChange={(id) => {
const cluster = clusters?.find(cluster => cluster.id.toString() === id);
context.setCluster(cluster);
}}
selectedKey={context.cluster?.id.toString()}
>
{clusters?.map(cluster => (
<Dropdown.Item id={cluster.id.toString()} key={cluster.id}>
{cluster.name}
</Dropdown.Item>
))}
</Dropdown>
</div>
<div className="flex flex-col gap-y-1">
<h6 className="my-1">Provider</h6>
<Dropdown onSelectionChange={id => context.setProvider(id as typeof context.provider)} selectedKey={context.provider}>
{PROVIDERS.map(provider => (
<Dropdown.Item id={provider} key={provider}>
<div className="flex flex-row">
<ProviderIcon className="size-4 mr-2 self-center" provider={provider} />
{provider}
</div>
</Dropdown.Item>
))}
</Dropdown>
</div>
</div>
</div>
);
}
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 (
<div className="top-0 grid grid-cols-[1fr_auto] h-fit min-w-50 gap-y-6">
<div />
<div className="flex flex-col gap-y-6">
<Show when>
<div className="flex flex-col gap-y-2">
<h6 className="my-1 uppercase">Categories</h6>
{categories.map(category => (
<p
aria-selected={context.query.filters?.categories?.includes(category.id)}
className="text-md capitalize opacity-60 hover:opacity-90 text-fg-primary hover:text-fg-primary-hover aria-selected:opacity-100"
key={category.id}
onClick={()=>switchCategory(category.id)}
>
{category.display}
</p>
))}
</div>
</Show>
</div>
</div>
);
}
function BrowserToolbar() {
return (
<div className="w-full flex flex-row justify-between bg-page">
<div className="flex flex-row gap-2" />
<div className="flex flex-row gap-2">
<TextField
iconLeft={<SearchMdIcon />}
placeholder="Search for content"
/>
</div>
</div>
);
}

View file

@ -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<string>('');
const [newName, setNewName] = useState<string>('');
const [edit, setEdit] = useState<boolean>(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<string>(cluster.data?.icon_url as string);
const [newName, setNewName] = useState<string>(cluster.data?.name as string);
const [edit, setEdit] = useState<boolean>(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 (
<Sidebar.Page>
<h1>Overview</h1>
@ -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={<FolderIcon />}
title="Cluster Folder"
@ -58,7 +121,7 @@ function RouteComponent() {
<SettingsRow.Header>Cluster Actions</SettingsRow.Header>
<SettingsRow
children={(
<Button color="secondary" onClick={() => setEdit(!edit)}>{edit ? 'Save' : 'Edit'}</Button>
<Button color="secondary" onClick={handleEditMode}>{edit ? 'Save' : 'Edit'}</Button>
)}
description="Edit the cluster name and cover image."
icon={<Edit02Icon />}
@ -95,7 +158,7 @@ function RouteComponent() {
}
interface BannerProps {
cluster: Model | null | undefined;
cluster: ClusterModel | null | undefined;
editMode: boolean;
newName: string;
setNewName: Dispatch<SetStateAction<string>>;
@ -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 (
<div className="h-37 flex flex-row gap-x-2.5 rounded-xl bg-page-elevated p-2.5">
<div className="relative aspect-ratio-video h-full min-w-57 w-57 overflow-hidden border border-component-bg/10 rounded-lg">
<Show when={editMode}>
<div
className="absolute z-1 h-full w-full flex items-center justify-center bg-black/50 opacity-50 hover:opacity-100"
// onClick={launchFilePicker}
onClick={launchFilePicker}
>
<ImagePlusIcon className="h-12 w-12" />
</div>
</Show>
{/* <ClusterCover class="h-full w-full object-cover" cluster={props.cluster} override={props.newCover()} /> */}
<img src="https://github.com/emirsassan.png" />
<img
className="h-full w-full"
onError={(e) => {
console.error('Failed to load cluster icon:', image(), e);
(e.target as HTMLImageElement).src = DefaultInstancePhoto;
}}
src={image()}
/>
</div>
<div className="w-full flex flex-col justify-between gap-y-.5 overflow-hidden text-fg-primary">
@ -193,7 +286,7 @@ function Banner({
>
Played for
{' '}
<b>{cluster?.overall_played || 0}</b>
<b>{formatAsDuration(cluster?.overall_played || 0)}</b>
.
</span>
</div>
@ -204,8 +297,7 @@ function Banner({
<Button
children={<Share07Icon />}
color="secondary"
// disabled={props.editMode()}
isDisabled
isDisabled={editMode}
/>
</div>
</div>

View file

@ -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<ProcessPayload>,
});
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 (
<Sidebar.Page>
<ScrollableContainer>
<div className="h-full">
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col gap-y-2">
<div className="h-10 flex flex-row items-center justify-between gap-x-1">
<h1>Logs</h1>
<div className="flex flex-row gap-x-2">
<Button
color="secondary"
// isDisabled={missingLogs()}
// onClick={uploadAndOpenLog}
>
<Upload01Icon />
{' '}
Upload
</Button>
{/* <Dropdown
class="min-w-50"
disabled={missingLogs()}
onChange={changeLog}
>
<For each={logs() || ['None']}>
{(log) => {
const pretty = log.split('/').pop();
return (
<Dropdown.Row>
<div>
{pretty}
</div>
</Dropdown.Row>
);
}}
</For>
</Dropdown> */}
<Button
color="primary"
onClick={openLogsFolder}
>
<LinkExternal01Icon />
{' '}
Open Folder
</Button>
</div>
</div>
<Show
fallback={<span>No logs were found.</span>}
when={state.logs.length > 0}
>
<FormattedLog log={state.logs} />
{/* {JSON.stringify(state.logs)} */}
</Show>
</div>
</div>
</div>
</ScrollableContainer>
</Sidebar.Page>
);
}
interface FormattedLogProps {
log: ReadonlyArray<ProcessPayload>;
}
function FormattedLog(props: FormattedLogProps) {
const { log } = props;
return (
<div className="relative h-full flex flex-1 flex-col overflow-hidden rounded-lg bg-component-bg border border-component-border shadow-sm">
<div className="h-px bg-gradient-to-r from-transparent via-component-border to-transparent" />
<div className="h-full flex flex-1 overflow-auto font-medium font-mono text-sm">
<OverlayScrollbarsComponent
className="relative h-full flex-1"
>
<code className="log-container whitespace-pre flex flex-col text-sm p-2 gap-0.5">
{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 <Line key={lineKey} line={logLine} />;
})}
</code>
</OverlayScrollbarsComponent>
</div>
</div>
);
}
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 (
<span
className={`
block py-0.5 rounded-sm hover:bg-component-bg-hover transition-colors duration-150 select-text
text-fg-secondary leading-relaxed
${isEmpty ? 'h-5 hover:bg-transparent' : ''}
`}
>
{processedLine}
</span>
);
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 (
<span
className={`
block py-0.5 rounded-sm hover:bg-component-bg-hover transition-colors duration-150 select-text
${getLevelColor(logLevel)} leading-relaxed
${isEmpty ? 'h-5 hover:bg-transparent' : ''}
`}
data-level={logLevel}
{...(isChatMsg ? { 'data-chat': 'true' } : {})}
{...(isEmpty ? { 'data-empty': 'true' } : {})}
>
<span className="text-fg-secondary opacity-60 font-medium text-xs mr-2 inline-block min-w-[4.5rem] text-right">
{prefix[1]}
</span>
<span className="font-semibold opacity-90 text-xs mr-2">
{`[${prefix[2]}/${prefix[3]}]`}
</span>
{isChatMsg && (
<span className="bg-brand text-white px-1.5 py-0.5 rounded text-xs font-medium mr-2">
CHAT
</span>
)}
<span className={`${isChatMsg ? 'text-white font-medium' : ''} break-words`}>
{processedLine.slice(prefix[0].length)}
</span>
</span>
);
};
return (
<>{format(line.type === 'Output' ? line.output : '')}</>
);
}

View file

@ -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 (
<Sidebar.Page>
<h1>Screenshots</h1>
@ -16,11 +24,12 @@ function RouteComponent() {
<div className="h-full">
<Show
fallback={<div className="text-border/400">No screenshots found. Press F2 in game to take a screenshot!</div>}
when
// @ts-ignore idk
when={result.data?.length > 0}
>
<div className="grid grid-cols-[repeat(auto-fill,minmax(350px,1fr))] w-full transform-gpu gap-2">
{arr.map(data => (
<ScreenshotEntry cluster_path="cluster" key={data} path={`screenshot-${data}.png`} />
{result.data?.map(data => (
<ScreenshotEntry cluster_path={cluster.data?.folder_name || ''} key={data} path={data} />
))}
</div>
</Show>
@ -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<string>('');
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 (
<div
className="flex flex-col items-center gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover hover:opacity-80"
onClick={onClick}
>
<img alt={path} className="aspect-video w-full rounded-lg" src="https://github.com/emirsassan.png" />
</div>
<>
<div
className="flex flex-col items-center gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover hover:opacity-80 cursor-pointer"
>
<img
alt={path}
className="aspect-video w-full rounded-lg"
src={imagePath}
/>
</div>
</>
);
}

View file

@ -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 <p>loading...</p>;
return (
<Sidebar.Page>
<ScrollableContainer>
<div className="h-full">
<h1>Minecraft Settings</h1>
<GameSettings />
<GameSettings key={_result.data?.name} settings={_result.data!} />
<ProcessSettings />
<ProcessSettings key={_result.data?.name} settings={_result.data!} />
</div>
</ScrollableContainer>
</Sidebar.Page>

View file

@ -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 (
<Sidebar.Page>
<h1>Worlds</h1>
<ScrollableContainer>
<div className="h-full">
<Show
fallback={<div className="text-border/400">No worlds found.</div>}
when={list.data !== undefined && list.data.length > 0}
>
<div className="flex flex-col gap-2">
{list.data?.map(data => (
<WorldEntry cluster_path={cluster.data?.folder_name || ''} key={data} name={data} />
))}
</div>
</Show>
</div>
</ScrollableContainer>
</Sidebar.Page>
);
}
function WorldEntry(props: { name: string; cluster_path: string }) {
const { name, cluster_path } = props;
return (
<div
className="flex flex-row items-center justify-between gap-3 rounded-xl bg-component-bg p-3 active:bg-component-bg-pressed hover:bg-component-bg-hover"
>
<div className="flex flex-row items-center gap-x-3">
<WorldIcon className="aspect-ratio-square h-16 w-16" cluster_name={cluster_path} world_name={name} />
<div className="flex flex-col gap-y-2">
<h3>{name}</h3>
<p>Todo</p>
</div>
</div>
<div className="flex flex-row items-center justify-end gap-x-3">
<Button
children={<Trash01Icon />}
color="danger"
/>
</div>
</div>
);
}

View file

@ -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 (
<div>
<p>this is really became my test place fr</p>
<TextField />
{/* we can open the modal but we cant close it for some reason */}
<Modal.Trigger>
<Button>Open Modal</Button>
<Modal>
<p>sadsadsad</p>
</Modal>
</Modal.Trigger>
<div className="max-w-96">
<Dropdown label="Select a version">
<Dropdown.Item>Slmalr</Dropdown.Item>
<Dropdown.Item>Slmalr 2</Dropdown.Item>
</Dropdown>
</div>
<div className="flex gap-2 flex-wrap">
<Button onClick={addSimpleNotif}>Add Simple Notification</Button>
<Button onClick={addProgressNotif}>Add Progress Notification</Button>
<Button onClick={addErrorNotif}>Add Error Notification</Button>
<Button onClick={clear}>Clear All Notifications</Button>
</div>
<div className="mt-4">
<h3 className="font-semibold mb-2">
Current Notifications (
{Object.keys(list).length}
):
</h3>
<pre className="text-xs bg-component-bg-hover p-2 rounded overflow-auto max-h-40">
{JSON.stringify(list, null, 2)}
</pre>
</div>
<div className="flex flex-row">
<div className="w-1/2">
<div className="mt-4">
<h3>Settings</h3>
<pre className="text-xs bg-component-bg-hover p-2 rounded overflow-auto max-h-40">
{JSON.stringify(result.data, null, 2)}
</pre>
</div>
<div className="mt-4">
<h3>Settings again but this time its from useSettings hook</h3>
<pre className="text-xs bg-component-bg-hover p-2 rounded overflow-auto max-h-40">
{JSON.stringify(settings, null, 2)}
</pre>
</div>
</div>
<div className="w-1/2">
<div className="ml-2">
<pre>
{parallel ? 'Parallel running is enabled' : 'Parallel running is disabled'}
</pre>
<Switch defaultSelected={parallel} onChange={val => setParallel(val)} />
</div>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="relative h-52 min-h-52 w-full overflow-hidden rounded-xl border border-component-border">
<div className="absolute h-52 min-h-52 w-full">
<img
alt="Default Banner"
className="top-0 left-0 h-full rounded-xl w-full object-cover"
src={cluster?.icon_url || DefaultBanner}
/>
<div className="linearBlur after:right-1/3">
<img
alt="Default Banner"
className="top-0 left-0 h-full rounded-xl w-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = DefaultBanner;
}}
src={image()}
/>
</div>
</div>
<div className="relative z-10 h-full flex flex-col items-start justify-between px-8 py-6">
<div className="theme-OneLauncher-Dark flex flex-col gap-y-2 text-fg-primary">
<h1 className="text-2xl font-medium text-shadow-black text-shadow-2xs">Create a cluster</h1>
<h1 className="text-2xl font-medium text-shadow-black text-shadow-2xs">{cluster?.name || 'Create a cluster'}</h1>
<Show when={cluster !== undefined}>
<p>
You've played
{' '}
<strong>
{cluster?.name}
{cluster?.mc_version}
{' '}
{upperFirst(cluster?.mc_loader || 'Unknown')}
</strong>
{' '}
for
{' '}
<strong>{cluster?.overall_played || 0}</strong>
<strong>{formatAsDuration(cluster?.overall_played || 0)}</strong>
.
</p>
</Show>
@ -117,7 +129,7 @@ function Banner() {
}
interface ClusterGroupProps {
clusters: Array<Model> | undefined;
clusters: Array<ClusterModel> | 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 (
<>
<Link
disabled={stage === 'downloading'}
search={{
id,
}} to="/app/cluster"
}}
to="/app/cluster"
>
<div
className="group relative h-[152px] flex flex-col rounded-xl border border-component-border/5 bg-component-bg active:bg-component-bg-pressed hover:bg-component-bg-hover"
@ -193,48 +203,22 @@ function ClusterCard({
>
<img
className="h-full w-full object-cover"
src={DefaultInstancePhoto}
onError={(e) => {
(e.target as HTMLImageElement).src = DefaultInstancePhoto;
}}
src={image()}
/>
</div>
</div>
<div className="z-10 flex flex-row items-center justify-between gap-x-3 p-3">
<div className="h-full flex flex-col gap-1.5 overflow-hidden">
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">{name}</p>
<p className="h-4 text-xs">
{mc_loader}
{' '}
{mc_version}
{/* {' '}
<>
<Link
search={{
id,
}} to="/app/cluster"
>
<div
className="group relative h-[152px] flex flex-col rounded-xl border border-component-border/5 bg-component-bg active:bg-component-bg-pressed hover:bg-component-bg-hover"
>
<div className="relative flex-1 overflow-hidden rounded-t-xl">
<div
className="absolute h-full w-full transition-transform group-hover:!scale-110"
>
<img
className="h-full w-full object-cover"
src={DefaultInstancePhoto}
/>
</div>
</div>
<div className="z-10 flex flex-row items-center justify-between gap-x-3 p-3">
<div className="h-full flex flex-col gap-1.5 overflow-hidden">
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">{name}</p>
<p className="h-4 text-xs">
{mc_loader}
{' '}
{mc_version}
{/* {' '}
{props.packages.mods && `• ${props.mods} mods`} */}
<p className="h-4 text-ellipsis whitespace-nowrap font-medium">
{name}
</p>
</div>
<p className="h-4 text-xs">
{mc_loader}
{' '}
{mc_version}
</p>
</div>

View file

@ -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 (
<Sidebar.Page>
@ -47,7 +52,7 @@ function RouteComponent() {
</ScrollableContainer>
<div className="mt-auto pt-2 flex flex-row items-end justify-end">
<Button color="primary">
<Button color="primary" onClick={() => addAccount.refetch()}>
<UserPlus02Icon />
{' '}
Add Account

View file

@ -1,5 +1,7 @@
import ScrollableContainer from '@/components/ScrollableContainer';
import SettingsRow from '@/components/SettingsRow';
import useSettings from '@/hooks/useSettings';
import { bindings } from '@/main';
import { THEMES } from '@/utils/theming';
import { Switch } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
@ -11,6 +13,15 @@ export const Route = createFileRoute('/app/settings/appearance')({
});
function RouteComponent() {
const { settings, createSetting } = useSettings();
const [windowFrame, setFrameStyle] = createSetting('native_window_frame', settings?.native_window_frame);
const handleWindowFrameChange = async (value: boolean) => {
await bindings.onelauncher.set_window_style(value);
setFrameStyle(value);
};
return (
<Sidebar.Page>
<ScrollableContainer>
@ -56,7 +67,7 @@ function RouteComponent() {
icon={<Monitor01Icon />}
title="Custom Window Frame"
>
<Switch />
<Switch defaultSelected={windowFrame} onChange={handleWindowFrameChange} />
</SettingsRow>
<SettingsRow

View file

@ -1,10 +1,12 @@
import DiscordIcon from '@/assets/logos/discord.svg';
import SettingsRow from '@/components/SettingsRow';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import useSettings from '@/hooks/useSettings';
import { Button, Switch } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { FolderIcon, LinkExternal01Icon, XIcon } from '@untitled-theme/icons-react';
import { dataDir, join } from '@tauri-apps/api/path';
import { openPath } from '@tauri-apps/plugin-opener';
import { FolderIcon, LinkExternal01Icon } from '@untitled-theme/icons-react';
import { useEffect, useState } from 'react';
import Sidebar from './route';
export const Route = createFileRoute('/app/settings/')({
@ -12,22 +14,38 @@ export const Route = createFileRoute('/app/settings/')({
});
function RouteComponent() {
const _result = useCommand('getGlobalProfile', bindings.core.getGlobalProfile);
const { settings, createSetting } = useSettings();
const [launcherDir, setLauncherDir] = useState('');
useEffect(() => {
(async () => {
setLauncherDir(await join(await dataDir(), 'OneLauncher'));
})();
}, []);
const openLauncherDir = async () => {
openPath(launcherDir);
};
const [discordRpc, setDiscordRpc] = createSetting('discord_enabled', settings?.discord_enabled);
return (
<Sidebar.Page>
<div className="h-full">
<h1>General Settings</h1>
<h1>General</h1>
<SettingsRow
description="Enable Discord Rich Presence."
icon={<img className="w-6 invert-100" src={DiscordIcon} />}
title="Discord RPC"
>
<Switch />
<Switch
defaultSelected={discordRpc}
onChange={val => setDiscordRpc(val)}
/>
</SettingsRow>
<SettingsRow
{/* <SettingsRow
description="Hide the confirmation dialog when closing the launcher."
icon={<XIcon />}
title="Hide Close Dialog"
@ -35,7 +53,7 @@ function RouteComponent() {
<Switch />
</SettingsRow>
{/* <SettingsRow
<SettingsRow
description="Sends errors and crash logs using Sentry to help developers fix issues. (// TODO)"
icon={<AlertSquareIcon />}
title="Error Analytics"
@ -48,11 +66,11 @@ function RouteComponent() {
<SettingsRow.Header>Folders and Files</SettingsRow.Header>
<SettingsRow
description="Unknown for now"
description={launcherDir}
icon={<FolderIcon />}
title="Launcher Folder"
>
<Button size="normal">
<Button onClick={openLauncherDir} size="normal">
<LinkExternal01Icon />
{' '}
Open

View file

@ -1,5 +1,9 @@
import type { SettingProfileModel } from '@/bindings.gen';
import ScrollableContainer from '@/components/ScrollableContainer';
import SettingsRow from '@/components/SettingsRow';
import usePopState from '@/hooks/usePopState';
import { bindings } from '@/main';
import { useCommand } from '@onelauncher/common';
import { Switch, TextField } from '@onelauncher/common/components';
import { createFileRoute } from '@tanstack/react-router';
import { Database01Icon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, XIcon } from '@untitled-theme/icons-react';
@ -10,24 +14,41 @@ export const Route = createFileRoute('/app/settings/minecraft')({
});
function RouteComponent() {
const result = useCommand('readSettings', bindings.core.readSettings);
const save = useCommand('writeSettings', () => bindings.core.writeSettings(result.data!), {
enabled: false,
subscribed: false,
});
usePopState(() => {
save.refetch();
});
return (
<Sidebar.Page>
<ScrollableContainer>
<div className="h-full">
<h1>Minecraft Settings</h1>
<GameSettings />
<GameSettings settings={result.data!.global_game_settings} />
{/* <LauncherSettings /> */}
<ProcessSettings />
<ProcessSettings settings={result.data!.global_game_settings} />
</div>
</ScrollableContainer>
</Sidebar.Page>
);
}
export function GameSettings() {
interface GameSettingProps {
settings: SettingProfileModel;
}
export function GameSettings(props: GameSettingProps) {
const { settings } = props;
return (
<>
<SettingsRow.Header>Game</SettingsRow.Header>
@ -37,7 +58,12 @@ export function GameSettings() {
icon={<Maximize01Icon />}
title="Force Fullscreen"
>
<Switch />
<Switch
defaultSelected={settings.force_fullscreen!}
onChange={(val) => {
settings.force_fullscreen = val;
}}
/>
</SettingsRow>
<SettingsRow
@ -48,11 +74,19 @@ export function GameSettings() {
<div className="grid grid-cols-[70px_16px_70px] gap-2 grid-justify-center grid-items-center">
<TextField
className="text-center"
defaultValue={settings.res?.width.toString()}
onChange={(e) => {
settings.res!.width = Number(e.target.value);
}}
type="number"
/>
<XIcon className="size-4 self-center" />
<TextField
className="text-center"
defaultValue={settings.res?.height.toString()}
onChange={(e) => {
settings.res!.height = Number(e.target.value);
}}
type="number"
/>
</div>
@ -65,21 +99,12 @@ export function GameSettings() {
>
<div className="flex items-center gap-x-4 flex-justify-center">
<div className="flex flex-row items-center gap-x-2">
<span>Min:</span>
<TextField
className="text-center"
max={2024}
min={1}
type="number"
/>
</div>
<div className="flex flex-row items-center gap-x-2">
<span>Max:</span>
<TextField
className="text-center"
max={2024}
min={1}
defaultValue={settings.mem_max?.toString()}
onChange={(e) => {
settings.mem_max = Number(e.target.value);
}}
type="number"
/>
</div>
@ -104,7 +129,13 @@ export function GameSettings() {
// );
// }
export function ProcessSettings() {
interface ProcessSettingsProps {
settings: SettingProfileModel;
}
export function ProcessSettings(props: ProcessSettingsProps) {
const { settings } = props;
return (
<>
<SettingsRow.Header>Process</SettingsRow.Header>
@ -115,6 +146,10 @@ export function ProcessSettings() {
title="Pre-Launch Command"
>
<TextField
defaultValue={settings.hook_pre || ''}
onChange={(e) => {
settings.hook_pre = e.target.value || null;
}}
placeholder="echo 'Game started'"
/>
</SettingsRow>
@ -125,6 +160,10 @@ export function ProcessSettings() {
title="Wrapper Command"
>
<TextField
defaultValue={settings.hook_wrapper || ''}
onChange={(e) => {
settings.hook_wrapper = e.target.value || null;
}}
placeholder="gamescope"
/>
</SettingsRow>
@ -135,6 +174,10 @@ export function ProcessSettings() {
title="Post-Exit Command"
>
<TextField
defaultValue={settings.hook_post || ''}
onChange={(e) => {
settings.hook_post = e.target.value || null;
}}
placeholder="echo 'Game exited'"
/>
</SettingsRow>

View file

@ -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) => (
<div className="flex flex-col gap-y-2" key={i}>
<div>
<h3 className="m-1.5 mt-5 text-xs text-fg-secondary font-medium">{section.toUpperCase()}</h3>
<p className="m-1.5 mt-5 text-xs text-fg-secondary font-medium">{section.toUpperCase()}</p>
<div className="flex flex-col gap-y-1 fill-fg-primary text-fg-primary font-medium">
{links[section].map((link, i) => {
// eslint yaps too much tbh
@ -88,7 +99,7 @@ function Sidebar(props: SidebarProps) {
return (
<a
className={
`px-3 py-1 rounded-md text-md border border-component-bg hover:bg-component-bg-hover active:bg-component-bg-pressed ${isActive(link[2], link[3]) ? 'bg-component-bg border-border/05' : 'border-transparent'}`
`px-3 py-1 rounded-md text-md border hover:bg-component-bg-hover active:bg-component-bg-pressed ${isActive(link[2], link[3]) ? 'bg-component-bg border-component-bg-pressed' : 'border-transparent'}`
}
key={i}
onClick={() => goto(link[2], link[3])}

View file

@ -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,

View file

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

View file

@ -0,0 +1,95 @@
export const BROWSER_VIEWS: Array<string> = ['grid', 'list'] as const;
export interface CategoryItem {
display: string;
id: string;
}
export const browserCategories = {
byPackageType(packageType: 'mod' | string, provider: string): Array<CategoryItem> {
switch (packageType) {
case 'mod':
return this.mod(provider);
}
return [];
},
mod(provider: string = 'Modrinth'): Array<CategoryItem> {
return Object.values(modMapping[provider]);
},
};
const modMapping: Record<string, Array<CategoryItem>> = {
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: [],
};

View file

@ -1,4 +1,4 @@
export const LAUNCHER_IMPORT_TYPES: Array<string> = [
export const LAUNCHER_IMPORT_TYPES = [
'PrismLauncher',
'Curseforge',
// 'Modrinth',
@ -10,6 +10,17 @@ export const LAUNCHER_IMPORT_TYPES: Array<string> = [
// '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<string> = [];
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);
}

View file

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

View file

@ -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<Vec<String>> {
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<Vec<String>> {
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)
}

View file

@ -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<Vec<(cluster_groups::Model
Ok(grouped)
}
/// Caches an icon from a file path and updates the cluster 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_cluster_icon_by_path(
cluster: &mut clusters::Model,
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()))?;
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<clusters::Model> {
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)
}

View file

@ -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::*;

View file

@ -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<u32>,
}
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<java_versions::Model> {
let id = init_ingress(
IngressType::JavaPrepare,
"preparing java",
100.0,
)
.await?;
pub async fn prepare_java(major: u32) -> LauncherResult<java_versions::Model> {
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<HashMap<PathBuf, JavaInfo>> {
#[cfg(target_os = "windows")]
fn internal_locate_java() -> Vec<PathBuf> {
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<PathBuf> {
found
}
pub async fn get_zulu_packages() -> LauncherResult<Vec<JavaPackage>> {
fetch_json::<Vec<JavaPackage>>(
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<PathBuf> {
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<PathBuf> {
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::<Vec<_>>().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)
}

View file

@ -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<String>,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub sort: Option<String>,
pub sort: Option<Sort>,
pub filters: Option<Filters>,
}
#[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<String>,
pub display_categories: Vec<String>,
pub versions: Vec<String>,
pub downloads: usize,
pub icon_url: String,
pub date_created: DateTime<Utc>,
pub date_modified: DateTime<Utc>,
pub latest_version: String,
pub license: String,
pub client_side: PackageSide,
pub server_side: PackageSide,
/// List of URLs to images
pub gallery: Vec<String>,
// pub color: i64,
}
#[onelauncher_macro::specta]
#[serde_as]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -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<Paginated<ManagedPackage>>;
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<SearchResult>>;
async fn get(&self, slug: &str) -> LauncherResult<ManagedPackage>;
async fn get_multiple(&self, slugs: &[String]) -> LauncherResult<Vec<ManagedPackage>>;
async fn get_versions_by_hashes(
@ -42,9 +42,9 @@ pub trait ProviderExt {
async fn get_versions(&self, slugs: &[String]) -> LauncherResult<Vec<ManagedVersion>>;
}
#[async_trait::async_trait(?Send)]
#[async_trait::async_trait]
impl ProviderExt for Provider {
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<ManagedPackage>> {
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<SearchResult>> {
match self {
Self::Modrinth => ModrinthProviderImpl.search(query).await,
_ => todo!("unimplemented provider"),

View file

@ -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<Paginated<ManagedPackage>> {
async fn search(&self, query: &SearchQuery) -> LauncherResult<Paginated<SearchResult>> {
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<SearchResult>,
pub offset: usize,
pub limit: usize,
pub total_hits: usize,
}
let response = http::fetch_json::<SearchResults>(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<ManagedPackage> {
@ -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<ModrinthUser> 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<Vec<Facet>>,
}
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<String> = 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(",")
}
}

View file

@ -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<uuid::Uuid>) -> 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<Vec<String>>;
#[taurpc(alias = "getWorlds")]
async fn get_worlds(id: ClusterId) -> LauncherResult<Vec<String>>;
// 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<SettingsProfile>;
// Game Metadata
#[taurpc(alias = "getGameVersions")]
async fn get_game_versions() -> LauncherResult<Vec<Version>>;
@ -62,6 +73,32 @@ pub trait TauriLauncherApi {
#[taurpc(alias = "openMsaLogin")]
async fn open_msa_login<R: Runtime>(app_handle: AppHandle<R>) -> LauncherResult<Option<MinecraftCredentials>>;
// Settings
#[taurpc(alias = "readSettings")]
async fn read_settings() -> LauncherResult<Settings>;
#[taurpc(alias = "writeSettings")]
async fn write_settings(setting: Settings) -> LauncherResult<()>;
// Packages
#[taurpc(alias = "searchPackages")]
async fn search_packages(provider: Provider, query: SearchQuery) -> LauncherResult<Paginated<SearchResult>>;
#[taurpc(alias = "getPackage")]
async fn get_package(provider: Provider, slug: String) -> LauncherResult<ManagedPackage>;
#[taurpc(alias = "getMultiplePackages")]
async fn get_multiple_packages(provider: Provider, slugs: Vec<String>) -> LauncherResult<Vec<ManagedPackage>>;
#[taurpc(alias = "getPackageVersions")]
async fn get_package_versions(provider: Provider, slug: String, mc_versions: Option<Vec<String>>, loaders: Option<Vec<GameLoader>>, offset: usize, limit: usize) -> LauncherResult<Paginated<ManagedVersion>>;
#[taurpc(alias = "getPackageUser")]
async fn get_package_user(provider: Provider, slug: String) -> LauncherResult<ManagedUser>;
#[taurpc(alias = "downloadPackage")]
async fn download_package(provider: Provider, package_id: String, version_id: String, cluster_id: ClusterId, skip_compatibility: Option<bool>) -> LauncherResult<packages::Model>;
}
@ -74,6 +111,23 @@ pub struct CreateCluster {
icon: Option<Icon>,
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)]
pub struct ClusterUpdate {
name: Option<String>,
icon_url: Option<Icon>,
}
#[derive(serde::Serialize, serde::Deserialize, specta::Type, Clone)]
pub struct ProfileUpdate {
pub res: Option<Resolution>,
pub force_fullscreen: Option<bool>,
pub mem_max: Option<u32>,
pub launch_args: Option<String>,
pub launch_env: Option<String>,
pub hook_pre: Option<String>,
pub hook_wrapper: Option<String>,
pub hook_post: Option<String>,
}
#[taurpc::ipc_type]
pub struct TauriLauncherApiImpl;
@ -97,6 +151,22 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
async fn create_cluster(self, options: CreateCluster) -> LauncherResult<Cluster> {
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<Vec<String>> {
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<Vec<String>> {
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<SettingsProfile> {
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<Vec<GameLoader>> {
@ -242,4 +369,60 @@ impl TauriLauncherApi for TauriLauncherApiImpl {
Ok(None)
}
async fn read_settings(self) -> LauncherResult<Settings> {
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<Paginated<SearchResult>> {
Ok(provider.search(&query).await?)
}
async fn get_package(self, provider: Provider, slug: String) -> LauncherResult<ManagedPackage> {
Ok(provider.get(&slug).await?)
}
async fn get_multiple_packages(self, provider: Provider, slugs: Vec<String>) -> LauncherResult<Vec<ManagedPackage>> {
Ok(provider.get_multiple(&slugs).await?)
}
async fn get_package_versions(self, provider: Provider, slug: String, mc_versions: Option<Vec<String>>, loaders: Option<Vec<GameLoader>>, offset: usize, limit: usize) -> LauncherResult<Paginated<ManagedVersion>> {
Ok(provider.get_versions_paginated(&slug, mc_versions, loaders, offset, limit).await?)
}
async fn get_package_user(self, provider: Provider, slug: String) -> LauncherResult<ManagedUser> {
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<bool>) -> LauncherResult<packages::Model> {
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)
}
}

View file

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

View file

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

View file

@ -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<T>
where T: Clone + Serialize {
where T: Clone + Serialize + Send + Sync {
pub total: usize,
pub offset: usize,
pub limit: usize,
pub items: Vec<T>,
}
impl<T: Clone + Serialize> Debug for Paginated<T> {
impl<T: Clone + Serialize + Send + Sync> Debug for Paginated<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Paginated")
.field("total", &self.total)
@ -23,7 +23,7 @@ impl<T: Clone + Serialize> Debug for Paginated<T> {
}
}
impl <T: Clone + Serialize> Paginated<T> {
impl <T: Clone + Serialize + Send + Sync> Paginated<T> {
#[must_use]
pub const fn new(total: usize, offset: usize, limit: usize, items: Vec<T>) -> Self {
Self { total, offset, limit, items }

View file

@ -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<Self> {

View file

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

View file

@ -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<TValue extends object>(props: DropdownProps<TValue>) {
<AriaPopover className={twMerge(styles.popover(), popoverClassName)}>
<div className={styles.popoverContentWrapper()}>
<AriaListBox className={styles.listBox()}>
{children}
</AriaListBox>
<OverlayScrollbarsComponent className={styles.overlayScrollbars()}>
<AriaListBox className={styles.listBox()}>
{children}
</AriaListBox>
</OverlayScrollbarsComponent>
{listToolRow && (
<div className={styles.listToolRowWrapper()}>
{listToolRow}

View file

@ -0,0 +1,22 @@
import type { PopoverProps } from 'react-aria-components';
import { Dialog, DialogTrigger, Popover } from 'react-aria-components';
interface MyPopoverProps extends Omit<PopoverProps, 'children'> {
children: React.ReactNode;
}
export function Popup({ children, ...props }: MyPopoverProps) {
return (
<Popover {...props} className="flex flex-col gap-0.5 rounded-lg border border-component-border-hover">
<Dialog className="bg-page-elevated p-2 shadow-md rounded-lg">
{children}
</Dialog>
</Popover>
);
}
function Trigger({ children, ...props }: any) {
return <DialogTrigger children={children} {...props} />;
}
Popup.Trigger = Trigger;

View file

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

View file

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

View file

@ -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: './',
},
],

40
pnpm-lock.yaml generated
View file

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