feat: Cluster + Global settings (with overrides!)

This commit is contained in:
LynithDev 2024-08-06 21:57:05 +02:00
parent 574d783821
commit 1b46c5da2e
14 changed files with 662 additions and 132 deletions

View file

@ -68,9 +68,31 @@ pub async fn create_cluster(props: CreateCluster) -> Result<Uuid, String> {
}
}
/// Updates the cluster with the given UUID. The cluster only updates game setting fields
#[specta::specta]
#[tauri::command]
pub async fn edit_cluster(
pub async fn edit_game_settings(uuid: Uuid, new_cluster: Cluster) -> Result<(), String> {
let cluster_path = ClusterPath::find_by_uuid(uuid).await?;
cluster::edit(&cluster_path, |old| {
// Game
old.force_fullscreen = new_cluster.force_fullscreen;
old.resolution = new_cluster.resolution;
old.memory = new_cluster.memory;
// Process
async move { Ok(()) }
}).await?;
State::sync().await?;
Ok(())
}
#[specta::specta]
#[tauri::command]
pub async fn edit_cluster_meta(
uuid: Uuid,
name: Option<String>,
icon_path: Option<String>,

View file

@ -29,7 +29,8 @@ macro_rules! collect_commands {
remove_user,
// Cluster
create_cluster,
edit_cluster,
edit_game_settings,
edit_cluster_meta,
remove_cluster,
get_cluster,
get_clusters,

View file

@ -3,7 +3,7 @@ import { Transition, type TransitionProps } from 'solid-transition-group';
import useSettingsContext from '~ui/hooks/useSettings';
function AnimatedRoutes(props: TransitionProps & ParentProps) {
const settings = useSettingsContext();
const { settings } = useSettingsContext();
const defaultProps: TransitionProps = {
mode: 'outin',
@ -20,7 +20,7 @@ function AnimatedRoutes(props: TransitionProps & ParentProps) {
return (
<Show
when={settings.disable_animations !== true}
when={settings().disable_animations !== true}
fallback={(
<>{split.children}</>
)}

View file

@ -1,6 +1,6 @@
import type { JSX, ParentProps } from 'solid-js';
type SettingsRowProps = ParentProps & {
export type SettingsRowProps = ParentProps & {
title: JSX.Element;
description: JSX.Element;
icon: JSX.Element;

View file

@ -23,7 +23,7 @@ function TitlebarButton(props: TitlebarButtonProps) {
}
function WindowFrame() {
const settings = useSettingsContext();
const { settings } = useSettingsContext();
const maximize = () => Window.getCurrent().toggleMaximize();
const minimize = () => Window.getCurrent().minimize();
@ -43,7 +43,7 @@ function WindowFrame() {
onMount(() => {
Window.getCurrent().onCloseRequested((event) => {
if (settings.hide_close_prompt !== true) {
if (settings().hide_close_prompt !== true) {
event.preventDefault();
modal.show();
}

View file

@ -14,7 +14,11 @@
@apply bg-brand/30;
}
@apply bg-transparent outline-none appearance-none p-0 m-0 box-border w-full;
@apply bg-transparent outline-none appearance-none p-0 m-0 box-border w-full font-medium;
&::placeholder {
@apply text-fg-secondary/40 font-normal;
}
}
& span {

View file

@ -1,13 +1,20 @@
import { type JSX, createSignal, splitProps } from 'solid-js';
import { type Accessor, type JSX, createEffect, createSignal, splitProps } from 'solid-js';
type ToggleProps = JSX.HTMLAttributes<HTMLDivElement> & {
defaultChecked?: boolean;
checked?: Accessor<boolean>;
onChecked?: (checked: boolean) => void;
};
function Toggle(props: ToggleProps) {
const [checked, setChecked] = createSignal(props.defaultChecked || false);
const [split, rest] = splitProps(props, ['class', 'defaultChecked', 'onChecked', 'text']);
// eslint-disable-next-line solid/reactivity -- ok
const [checked, setChecked] = createSignal(props.checked?.());
const [split, rest] = splitProps(props, ['class', 'checked', 'onChecked', 'text']);
// eslint-disable-next-line solid/reactivity -- i hate mylife
if (props.checked)
createEffect(() => {
setChecked(props.checked!());
});
function toggle() {
const newValue = !checked();

View file

@ -1,10 +1,9 @@
import { type Context, type ParentProps, type Resource, Show, createContext, createEffect, useContext } from 'solid-js';
import { type Context, type ParentProps, Show, createContext, createEffect, useContext } from 'solid-js';
import { useBeforeLeave } from '@solidjs/router';
import useCommand from './useCommand';
import type { Settings } from '~bindings';
import { bridge } from '~imports';
const SettingsContext = createContext<Settings>() as Context<Settings>;
/**
* Sync the launcher state (CSS, text etc) with the current settings
*/
@ -12,22 +11,43 @@ export function syncSettings(settings: Settings) {
document.body.classList.toggle('reduce-motion', settings.disable_animations);
}
export function getSettings(): Resource<Settings> | undefined {
const [resource] = useCommand(bridge.commands.getSettings);
return resource;
interface SettingsControllerType {
settings: () => Settings;
saveOnLeave: (settings: () => Partial<Settings>) => void;
save: (settings: Settings) => void;
}
const SettingsContext = createContext() as Context<SettingsControllerType>;
export function SettingsProvider(props: ParentProps) {
const settings = getSettings();
const [settings, { refetch }] = useCommand(bridge.commands.getSettings);
createEffect(() => {
if (settings !== undefined && settings() !== undefined)
syncSettings(settings!()!);
});
const controller: SettingsControllerType = {
settings: () => settings!()!,
saveOnLeave: (settings) => {
useBeforeLeave(() => {
controller.save({
...controller.settings(),
...settings(),
});
});
},
save: (settings) => {
bridge.commands.setSettings(settings).then(() => {
syncSettings(settings);
refetch();
});
},
};
return (
<Show when={settings !== undefined && settings() !== undefined}>
<SettingsContext.Provider value={settings!()!}>
<SettingsContext.Provider value={controller}>
{props.children}
</SettingsContext.Provider>
</Show>
@ -40,21 +60,7 @@ export function useSettingsContext() {
if (!context)
throw new Error('useSettingsContext should be called inside its SettingsProvider');
const contextProxy = new Proxy(context, {
set(target, key, value) {
// @ts-expect-error typescript
target[key] = value;
save(target); // TODO: Possibly make a "Save confirmation" instead of writing to the settings file every time?
return true;
},
});
return contextProxy;
}
function save(settings: Settings) {
bridge.commands.setSettings(settings);
syncSettings(settings);
return context;
}
export default useSettingsContext;

View file

@ -13,7 +13,7 @@ import FormattedLog from '~ui/components/content/FormattedLog';
function ClusterLogs() {
const [cluster] = useClusterContext();
const [logs] = useCommand(bridge.commands.getClusterLogs, cluster()!.uuid);
const settings = useSettingsContext();
const { settings } = useSettingsContext();
const [activeLogFile, setActiveLogFile] = createSignal<string | null>(null);
const [logContent, setLogContent] = createSignal<string | null>(null);
@ -31,7 +31,7 @@ function ClusterLogs() {
}
async function openFolder() {
const root = settings.config_dir;
const root = settings().config_dir;
const path = cluster()!.path;
if (root === null || root === undefined || path === null || path === undefined)
return;

View file

@ -21,7 +21,7 @@ import type { ModalProps } from '~ui/components/overlay/Modal';
import Modal, { createModal } from '~ui/components/overlay/Modal';
function ClusterOverview() {
const settings = useSettingsContext();
const { settings } = useSettingsContext();
const [cluster, { refetch }] = useClusterContext();
const [editMode, setEditMode] = createSignal(false);
@ -47,7 +47,7 @@ function ClusterOverview() {
function getPath() {
const clusterPath = cluster()?.path;
const configDir = settings.config_dir;
const configDir = settings().config_dir;
if (typeof clusterPath !== 'string' || typeof configDir !== 'string')
return '';
@ -95,7 +95,7 @@ function ClusterOverview() {
if (!c)
return;
await bridge.commands.editCluster(
await bridge.commands.editClusterMeta(
c.uuid,
newName().length > 0 ? newName() : null,
newCover().length > 0 ? newCover() : c.meta.icon_url || c.meta.icon || null,

View file

@ -1,34 +1,305 @@
import { useBeforeLeave } from '@solidjs/router';
import { type Accessor, type Setter, Show, createEffect, createSignal, splitProps, untrack } from 'solid-js';
import { CpuChip01Icon, Database01Icon, EyeIcon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, VariableIcon, XIcon } from '@untitled-theme/icons-solid';
import { createSetting } from '../settings/game/SettingsMinecraft';
import ScrollableContainer from '~ui/components/ScrollableContainer';
import type { SettingsRowProps } from '~ui/components/SettingsRow';
import Sidebar from '~ui/components/Sidebar';
import useClusterContext from '~ui/hooks/useCluster';
import BaseSettingsRow from '~ui/components/SettingsRow';
import type { Cluster } from '~bindings';
import useSettingsContext from '~ui/hooks/useSettings';
import SettingsMinecraft from '~ui/pages/settings/game/SettingsMinecraft';
import Toggle from '~ui/components/base/Toggle';
import TextField from '~ui/components/base/TextField';
import { asEnvVariables } from '~utils';
import { tryResult } from '~ui/hooks/useCommand';
import { bridge } from '~imports';
function ClusterSettings() {
const [cluster] = useClusterContext();
const settings = useSettingsContext();
// TODO: Save cluster settings
return (
<Sidebar.Page>
<h1>Overview</h1>
<ScrollableContainer>
<SettingsMinecraft.Settings
fullscreen={{
get: cluster()?.force_fullscreen ?? settings.force_fullscreen ?? false,
set: value => cluster() && (cluster()!.force_fullscreen = value),
isGlobal: cluster()?.force_fullscreen === null,
}}
resolution={{
get: cluster()?.resolution ?? settings.resolution,
set: value => cluster() && (cluster()!.resolution = value),
isGlobal: cluster()?.resolution === undefined || cluster()?.resolution === null,
}}
/>
<Show when={cluster() !== undefined}>
{PageSettings(() => cluster()!)}
</Show>
</ScrollableContainer>
</Sidebar.Page>
);
}
function SettingsRow(props: SettingsRowProps & {
reset: () => any;
isGlobal: Accessor<boolean>;
}) {
const [split, rest] = splitProps(props, ['children', 'reset', 'isGlobal']);
// TODO: Reset button doesn't show up immediately after changing a setting
return (
<BaseSettingsRow {...rest}>
<Show when={split.isGlobal() === false}>
<div
class="px-2 py-1 text-xs text-white bg-brand hover:bg-brand-hover active:bg-brand-pressed rounded-md font-italic font-medium"
onClick={() => split.reset()}
>
Reset
</div>
</Show>
{split.children}
</BaseSettingsRow>
);
}
function PageSettings(cluster: Accessor<Cluster>) {
const { settings } = useSettingsContext();
// Game
const fullscreen = createSetting(cluster().force_fullscreen, settings().force_fullscreen ?? false);
const resolution = createSetting(cluster().resolution, settings().resolution);
const memory = createSetting(cluster().memory, settings().memory);
// Launcher
// Process
const preCommand = createSetting(cluster().init_hooks?.pre, settings().init_hooks.pre ?? '');
const wrapperCommand = createSetting(cluster().init_hooks?.wrapper, settings().init_hooks.wrapper ?? '');
const postCommand = createSetting(cluster().init_hooks?.post, settings().init_hooks.post ?? '');
// JVM
const javaArgs = createSetting(cluster().java?.custom_arguments, settings().custom_java_args);
const envVars = createSetting(cluster().java?.custom_env_arguments, settings().custom_env_args);
useBeforeLeave(() => {
tryResult(bridge.commands.editGameSettings, cluster().uuid, {
...cluster(),
// Game
force_fullscreen: fullscreen.getRaw(),
resolution: resolution.getRaw(),
memory: memory.getRaw(),
// Process
init_hooks: {
pre: preCommand.getRaw(),
wrapper: wrapperCommand.getRaw(),
post: postCommand.getRaw(),
},
// JVM
java: {
custom_arguments: javaArgs.getRaw(),
custom_env_arguments: envVars.getRaw(),
},
});
});
const GameSettings = () => (
<>
<BaseSettingsRow.Header>Game</BaseSettingsRow.Header>
<SettingsRow
title="Force Fullscreen"
description="Force Minecraft to start in fullscreen mode."
icon={<Maximize01Icon />}
isGlobal={fullscreen.isGlobal}
reset={fullscreen.resetToFallback}
>
<Toggle
checked={fullscreen.get}
onChecked={fullscreen.set}
/>
</SettingsRow>
<SettingsRow
title="Resolution"
description="The game window resolution in pixels."
icon={<LayoutTopIcon />}
isGlobal={resolution.isGlobal}
reset={resolution.resetToFallback}
>
<div class="grid grid-justify-center grid-items-center gap-2 grid-cols-[70px_16px_70px]">
<TextField.Number
class="text-center"
value={resolution.get()[0]}
onValidSubmit={(value) => {
resolution.set([Number.parseInt(value), resolution.get()[1]]);
}}
/>
<XIcon class="w-4 h-4" />
<TextField.Number
class="text-center"
value={resolution.get()[1]}
onValidSubmit={(value) => {
resolution.set([resolution.get()[0], Number.parseInt(value)]);
}}
/>
</div>
</SettingsRow>
<SettingsRow
title="Memory"
description="The amount of memory in megabytes allocated for the game."
icon={<Database01Icon />}
isGlobal={memory.isGlobal}
reset={memory.resetToFallback}
>
<div class="flex flex-justify-center items-center gap-x-4">
<div class="flex flex-row items-center gap-x-2">
<span>Min:</span>
<TextField.Number
class="text-center"
labelClass="w-[70px]!"
value={memory.get().minimum}
onValidSubmit={(value) => {
memory.set({ minimum: Number.parseInt(value), maximum: memory.get().maximum });
}}
/>
</div>
<div class="flex flex-row items-center gap-x-2">
<span>Max:</span>
<TextField.Number
class="text-center"
labelClass="w-[70px]!"
value={memory.get().maximum}
onValidSubmit={(value) => {
memory.set({ minimum: memory.get().minimum, maximum: Number.parseInt(value) });
}}
/>
</div>
</div>
</SettingsRow>
</>
);
const LauncherSettings = () => (
<>
{/* <BaseSettingsRow.Header>Launcher</BaseSettingsRow.Header>
<SettingsRow
title="Hide On Launch"
description="Hide the launcher whenever you start a game."
icon={<EyeIcon />}
>
<Toggle
checked={hideOnLaunch.get}
onChecked={hideOnLaunch.set}
/>
</SettingsRow> */}
</>
);
const ProcessSettings = () => (
<>
<BaseSettingsRow.Header>Process</BaseSettingsRow.Header>
<SettingsRow
title="Pre-Launch Command"
description="Command to run before launching the game."
icon={<FilePlus02Icon />}
isGlobal={preCommand.isGlobal}
reset={preCommand.resetToFallback}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder="echo 'Game started'"
value={preCommand.get()}
onValidSubmit={preCommand.set}
/>
</SettingsRow>
<SettingsRow
title="Wrapper Command"
description="Command to run when launching the game."
icon={<ParagraphWrapIcon />}
isGlobal={wrapperCommand.isGlobal}
reset={wrapperCommand.resetToFallback}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder="gamescope"
value={wrapperCommand.get()}
onValidSubmit={wrapperCommand.set}
/>
</SettingsRow>
<SettingsRow
title="Post-Exit Command"
description="Command to run after exiting the game."
icon={<FileX02Icon />}
isGlobal={postCommand.isGlobal}
reset={postCommand.resetToFallback}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder="echo 'Game exited'"
value={postCommand.get()}
onValidSubmit={postCommand.set}
/>
</SettingsRow>
</>
);
const JvmSettings = () => (
<>
<BaseSettingsRow.Header>Java</BaseSettingsRow.Header>
<SettingsRow
title="Version"
description="Choose the JRE (Java Runtime Environment) used for the game."
icon={<CpuChip01Icon />}
isGlobal={() => true}
reset={() => {}}
/>
<SettingsRow
title="Arguments"
description="Additional arguments passed to the JVM (Java Virtual Machine)."
icon={<FilePlus02Icon />}
isGlobal={javaArgs.isGlobal}
reset={javaArgs.resetToFallback}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder='"-Xmx3G"'
value={javaArgs.get().join(' ')}
onValidSubmit={(value) => {
javaArgs.set(value.split(' '));
}}
/>
</SettingsRow>
<SettingsRow
title="Environment Variables"
description="Additional environment variables passed to the JVM (Java Virtual Machine)."
icon={<VariableIcon />}
isGlobal={envVars.isGlobal}
reset={envVars.resetToFallback}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder='"JAVA_HOME=/path/to/java"'
value={envVars.get().join(' ')}
onValidSubmit={(value) => {
envVars.set(asEnvVariables(value));
}}
/>
</SettingsRow>
</>
);
return (
<>
<GameSettings />
<LauncherSettings />
<ProcessSettings />
<JvmSettings />
</>
);
}
export default ClusterSettings;

View file

@ -7,7 +7,7 @@ import Sidebar from '~ui/components/Sidebar';
import useSettingsContext from '~ui/hooks/useSettings';
function SettingsDeveloper() {
const settings = useSettingsContext();
const { settings } = useSettingsContext();
return (
<Sidebar.Page>
@ -31,8 +31,8 @@ function SettingsDeveloper() {
icon={<GitMergeIcon />}
>
<Toggle
defaultChecked={settings.debug_mode ?? false}
onChecked={value => settings.debug_mode = value}
checked={() => settings().debug_mode ?? false}
onChecked={value => settings().debug_mode = value}
/>
</SettingsRow>

View file

@ -1,106 +1,309 @@
import { EyeIcon, LayoutTopIcon, Maximize01Icon, XIcon } from '@untitled-theme/icons-solid';
import { Show } from 'solid-js';
import { CpuChip01Icon, Database01Icon, EyeIcon, FilePlus02Icon, FileX02Icon, LayoutTopIcon, Maximize01Icon, ParagraphWrapIcon, VariableIcon, XIcon } from '@untitled-theme/icons-solid';
import { type Accessor, type Setter, createSignal, onMount, untrack } from 'solid-js';
import TextField from '~ui/components/base/TextField';
import Toggle from '~ui/components/base/Toggle';
import ScrollableContainer from '~ui/components/ScrollableContainer';
import SettingsRow from '~ui/components/SettingsRow';
import Sidebar from '~ui/components/Sidebar';
import useSettingsContext from '~ui/hooks/useSettings';
import { asEnvVariables } from '~utils';
// TODO: REFACTOR THIS ENTIRE FILE AND POSSIBLY `apps/frontend/src/ui/pages/cluster/ClusterSettings.tsx` TOO
function SettingsMinecraft() {
const settings = useSettingsContext();
return (
<Sidebar.Page>
<h1>Global Minecraft Settings</h1>
<ScrollableContainer>
<SettingsMinecraft.Settings
fullscreen={{
get: settings.force_fullscreen ?? false,
set: value => settings.force_fullscreen = value,
}}
resolution={{
get: settings.resolution,
set: value => settings.resolution = value,
}}
hide_on_launch={{
get: settings.hide_on_launch ?? false,
set: value => settings.hide_on_launch = value,
}}
/>
<PageSettings />
</ScrollableContainer>
</Sidebar.Page>
);
}
interface SettingsEntryType<T> {
get: T;
set: (value: T) => any;
isGlobal?: boolean; // TODO: Ask how "global settings sync" should work on pages like ClusterSettings
interface CreateSetting<T> {
get: Accessor<T>;
getRaw: () => T;
set: Setter<T>;
isGlobal: Accessor<boolean>;
resetToFallback: (raw?: any) => void;
};
export function createSetting<T>(initial: T | undefined | null, fallback?: T): CreateSetting<T> {
const [value, setValue] = createSignal<T>(initial || fallback as T);
const [raw, setRaw] = createSignal<T>(initial as T);
const [isGlobal, setIsGlobal] = createSignal(true);
interface SettingsProps {
fullscreen: SettingsEntryType<boolean>;
resolution: SettingsEntryType<[number, number]>;
hide_on_launch: SettingsEntryType<boolean>;
};
const checkGlobal = () => {
const check_one = untrack(() => value()) === undefined || untrack(() => value()) === null;
const check_two = initial === undefined || initial === null;
// const check_three = untrack(() => raw()) === undefined || untrack(() => raw()) === null;
SettingsMinecraft.Settings = function (props: Partial<SettingsProps>) {
return (
setIsGlobal(check_one || check_two);
};
onMount(() => {
checkGlobal();
});
// @ts-expect-error -- aaa
const set: Setter<T> = (value) => {
// @ts-expect-error -- aaa
setRaw(value);
// @ts-expect-error -- aaa
setValue(value);
checkGlobal();
};
const resetToFallback = (raw?: T) => {
if (fallback === undefined || fallback === null)
throw new Error('No fallback value provided');
setValue(() => fallback);
// @ts-expect-error -- aaa
setRaw(() => (raw || null));
checkGlobal();
};
const getRaw = () => raw();
return {
get: value,
getRaw,
set,
isGlobal,
resetToFallback,
};
}
function PageSettings() {
const { settings, saveOnLeave } = useSettingsContext();
// Game
const fullscreen = createSetting(settings().force_fullscreen ?? false);
const resolution = createSetting(settings().resolution);
const memory = createSetting(settings().memory);
// Launcher
const hideOnLaunch = createSetting(settings().hide_on_launch ?? false);
// Process
const preCommand = createSetting(settings().init_hooks.pre ?? '');
const wrapperCommand = createSetting(settings().init_hooks.wrapper ?? '');
const postCommand = createSetting(settings().init_hooks.post ?? '');
// JVM
const javaArgs = createSetting(settings().custom_java_args);
const envVars = createSetting(settings().custom_env_args);
saveOnLeave(() => ({
// Game
force_fullscreen: fullscreen.get(),
resolution: resolution.get(),
memory: memory.get(),
// Launcher
hide_on_launch: hideOnLaunch.get(),
// Process
init_hooks: {
pre: preCommand.get(),
wrapper: wrapperCommand.get(),
post: postCommand.get(),
},
// JVM
custom_java_args: javaArgs.get(),
custom_env_args: envVars.get(),
}));
const GameSettings = () => (
<>
<Show when={props.fullscreen !== undefined}>
<SettingsRow
title="Force Fullscreen"
description="Force Minecraft to start in fullscreen mode."
icon={<Maximize01Icon />}
>
<Toggle
defaultChecked={props.fullscreen!.get}
onChecked={props.fullscreen!.set}
/>
</SettingsRow>
</Show>
<SettingsRow.Header>Game</SettingsRow.Header>
<Show when={props.resolution !== undefined}>
<SettingsRow
title="Resolution"
description="The game window resolution in pixels."
icon={<LayoutTopIcon />}
>
<div class="grid grid-justify-center grid-items-center gap-2 grid-cols-[70px_16px_70px]">
<SettingsRow
title="Force Fullscreen"
description="Force Minecraft to start in fullscreen mode."
icon={<Maximize01Icon />}
>
<Toggle
checked={fullscreen.get}
onChecked={fullscreen.set}
/>
</SettingsRow>
<SettingsRow
title="Resolution"
description="The game window resolution in pixels."
icon={<LayoutTopIcon />}
>
<div class="grid grid-justify-center grid-items-center gap-2 grid-cols-[70px_16px_70px]">
<TextField.Number
class="text-center"
value={resolution.get()[0]}
onValidSubmit={(value) => {
resolution.set([Number.parseInt(value), resolution.get()[1]]);
}}
/>
<XIcon class="w-4 h-4" />
<TextField.Number
class="text-center"
value={resolution.get()[1]}
onValidSubmit={(value) => {
resolution.set([resolution.get()[0], Number.parseInt(value)]);
}}
/>
</div>
</SettingsRow>
<SettingsRow
title="Memory"
description="The amount of memory in megabytes allocated for the game."
icon={<Database01Icon />}
>
<div class="flex flex-justify-center items-center gap-x-4">
<div class="flex flex-row items-center gap-x-2">
<span>Min:</span>
<TextField.Number
class="text-center"
value={props.resolution!.get[0]}
labelClass="w-[70px]!"
value={memory.get().minimum}
onValidSubmit={(value) => {
props.resolution!.set([Number.parseInt(value), props.resolution!.get[1]]);
}}
/>
<XIcon class="w-4 h-4" />
<TextField.Number
class="text-center"
value={props.resolution!.get[1]}
onValidSubmit={(value) => {
props.resolution!.set([props.resolution!.get[0], Number.parseInt(value)]);
memory.set({ minimum: Number.parseInt(value), maximum: memory.get().maximum });
}}
/>
</div>
</SettingsRow>
</Show>
<Show when={props.hide_on_launch !== undefined}>
<SettingsRow
title="Hide On Launch"
description="Hide the launcher whenever you start a game."
icon={<EyeIcon />}
>
<Toggle
defaultChecked={props.hide_on_launch!.get}
onChecked={props.hide_on_launch!.set}
/>
</SettingsRow>
</Show>
<div class="flex flex-row items-center gap-x-2">
<span>Max:</span>
<TextField.Number
class="text-center"
labelClass="w-[70px]!"
value={memory.get().maximum}
onValidSubmit={(value) => {
memory.set({ minimum: memory.get().minimum, maximum: Number.parseInt(value) });
}}
/>
</div>
</div>
</SettingsRow>
</>
);
};
const LauncherSettings = () => (
<>
<SettingsRow.Header>Launcher</SettingsRow.Header>
<SettingsRow
title="Hide On Launch"
description="Hide the launcher whenever you start a game."
icon={<EyeIcon />}
>
<Toggle
checked={hideOnLaunch.get}
onChecked={hideOnLaunch.set}
/>
</SettingsRow>
</>
);
const ProcessSettings = () => (
<>
<SettingsRow.Header>Process</SettingsRow.Header>
<SettingsRow
title="Pre-Launch Command"
description="Command to run before launching the game."
icon={<FilePlus02Icon />}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder="echo 'Game started'"
value={preCommand.get()}
onValidSubmit={preCommand.set}
/>
</SettingsRow>
<SettingsRow
title="Wrapper Command"
description="Command to run when launching the game."
icon={<ParagraphWrapIcon />}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder="gamescope"
value={wrapperCommand.get()}
onValidSubmit={wrapperCommand.set}
/>
</SettingsRow>
<SettingsRow
title="Post-Exit Command"
description="Command to run after exiting the game."
icon={<FileX02Icon />}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder="echo 'Game exited'"
value={postCommand.get()}
onValidSubmit={postCommand.set}
/>
</SettingsRow>
</>
);
const JvmSettings = () => (
<>
<SettingsRow.Header>Java</SettingsRow.Header>
<SettingsRow
title="Version"
description="Choose the JRE (Java Runtime Environment) used for the game."
icon={<CpuChip01Icon />}
/>
<SettingsRow
title="Arguments"
description="Additional arguments passed to the JVM (Java Virtual Machine)."
icon={<FilePlus02Icon />}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder='"-Xmx3G"'
value={javaArgs.get().join(' ')}
onValidSubmit={(value) => {
javaArgs.set(value.split(' '));
}}
/>
</SettingsRow>
<SettingsRow
title="Environment Variables"
description="Additional environment variables passed to the JVM (Java Virtual Machine)."
icon={<VariableIcon />}
>
<TextField
labelClass="w-full! min-w-[260px]!"
placeholder='"JAVA_HOME=/path/to/java"'
value={envVars.get().join(' ')}
onValidSubmit={(value) => {
envVars.set(asEnvVariables(value));
}}
/>
</SettingsRow>
</>
);
return (
<>
<GameSettings />
<LauncherSettings />
<ProcessSettings />
<JvmSettings />
</>
);
}
export default SettingsMinecraft;

View file

@ -80,3 +80,19 @@ export function secondsToWords(seconds: number | bigint, plural: boolean = true)
return str;
}
export function asEnvVariables(str: string): [string, string][] {
return str
.split(' ')
.map((pair) => {
const split = pair.split('=', 1);
if (split.length === 0)
return null;
else if (split.length === 1)
return [split[0]!, ''] as [string, string];
else
return [split[0]!, split[1]!] as [string, string];
})
.filter(pair => pair !== null);
}