[feature] we can now create custom launch options (#861)

This commit is contained in:
MathieuG-P
2025-04-24 15:43:42 +02:00
committed by GitHub
parent cc4061fd38
commit 266feae483
17 changed files with 330 additions and 47 deletions
@@ -0,0 +1,60 @@
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { useTranslationV2 } from "renderer/hooks/use-translation.hook";
import BeatRunning from "../../../../../assets/images/apngs/beat-running.png";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import { CustomLaunchOption } from "renderer/components/version-viewer/slides/launch/launch-options-panel.component";
import { FormEvent, useState } from "react";
import { useConstant } from "renderer/hooks/use-constant.hook";
type Data = Partial<CustomLaunchOption>;
export const CreateCustomLaunchOptionModal: ModalComponent<CustomLaunchOption, Data> = ({resolver, options}) => {
const { text: t } = useTranslationV2();
const uid = useConstant(() => options?.data?.id ?? crypto.randomUUID());
const [name, setName] = useState(options?.data?.label ?? "");
const [command, setCommand] = useState(options?.data?.data?.command ?? "");
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
resolver({
exitCode: ModalExitCode.COMPLETED,
data: {
id: uid,
label: name,
data: {
command,
}
}
});
}
const cancel = () => {
resolver({
exitCode: ModalExitCode.CANCELED,
data: null,
});
}
return (
<form onSubmit={onSubmit} className="max-w-xs">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">{t("modals.custom-launch-option.title")}</h1>
<BsmImage className="mx-auto h-24" image={BeatRunning} />
<p className="mb-2">{t("modals.custom-launch-option.desc")}</p>
<div className="flex flex-col gap-px mb-3">
<label className="font-bold cursor-pointer tracking-wide inline-block w-fit" htmlFor="name">{t("modals.custom-launch-option.name")}</label>
<input className="h-8 px-1.5 bg-light-main-color-1 dark:bg-main-color-1 rounded-md" type="text" id="name" required value={name} onChange={e => setName(e.target.value)} placeholder={t("modals.custom-launch-option.title-placeholder")} />
</div>
<div className="flex flex-col gap-px mb-3">
<label className="font-bold cursor-pointer tracking-wide inline-block w-fit" htmlFor="launch-option">{t("modals.custom-launch-option.launch-option")}</label>
<input className="h-8 px-1.5 bg-light-main-color-1 dark:bg-main-color-1 rounded-md" type="text" id="launch-option" required value={command} onChange={e => setCommand(e.target.value)} placeholder="--launch-option" />
</div>
<div className="flex gap-3">
<BsmButton className="rounded-md flex justify-center items-center transition-all h-10 grow shrink-0" text={t("misc.cancel")} typeColor="cancel" withBar={false} onClick={cancel}/>
<BsmButton className="rounded-md flex justify-center items-center transition-all h-10 grow shrink-0" type="submit" text={t("misc.create")} typeColor="primary" withBar={false} />
</div>
</form>
)
};
@@ -4,8 +4,11 @@ import { PinIcon } from "renderer/components/svgs/icons/pin-icon.component";
import { UnpinIcon } from "renderer/components/svgs/icons/unpin-icon.component";
import { SvgIcon } from "renderer/components/svgs/svg-icon.type";
import { cn } from "renderer/helpers/css-class.helpers";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { useTranslationV2 } from "renderer/hooks/use-translation.hook";
import { LaunchMod } from "shared/models/bs-launch/launch-option.interface";
import { AddIcon } from "renderer/components/svgs/icons/add-icon.component";
import { EditIcon } from "renderer/components/svgs/icons/edit-icon.component";
import { TrashIcon } from "renderer/components/svgs/icons/trash-icon.component";
type Props = {
readonly className?: string;
@@ -13,17 +16,26 @@ type Props = {
readonly launchArgs?: string;
readonly launchMods?: LaunchModItemProps[];
readonly onLaunchArgsChange?: (args: string) => void;
readonly onAddLaunchMod?: (command?: string) => void;
};
export function LaunchOptionsPanel({ className, open, launchArgs, launchMods, onLaunchArgsChange }: Props) {
export function LaunchOptionsPanel({ className, open, launchArgs, launchMods, onLaunchArgsChange, onAddLaunchMod }: Props) {
const {text: t} = useTranslationV2();
const color = useThemeColor("first-color");
const { text: t } = useTranslationV2();
return (
<div className={cn("grid grid-rows-[0fr] transition-[grid-template-rows] bg-theme-2 rounded-md shadow-md shadow-black overflow-hidden", open && "!grid-rows-[1fr]", className)}>
<div className="flex flex-col overflow-y-scroll scrollbar-default">
<div className="p-3.5">
<input className="h-10 w-full rounded-md bg-theme-1 text-center mb-2" type="text" placeholder={t("pages.version-viewer.launch-mods.advanced-launch.placeholder")} value={launchArgs} onChange={e => onLaunchArgsChange(e.target.value)}/>
<div className="w-full relative flex justify-center items-center mb-2">
<input className="h-10 w-full rounded-md bg-theme-1 text-center" type="text" placeholder={t("pages.version-viewer.launch-mods.advanced-launch.placeholder")} value={launchArgs} onChange={e => onLaunchArgsChange(e.target.value)}/>
<Tippy theme="default" placement="top" content={t("pages.version-viewer.launch-mods.advanced-launch.create-launch-option")}>
<button className="absolute size-8 rounded-md p-0.5 right-1 hover:brightness-110" style={{ backgroundColor: color }} onClick={() => onAddLaunchMod?.(launchArgs)}>
<AddIcon className="size-full"/>
</button>
</Tippy>
</div>
<div className={cn("flex gap-2 flex-wrap")}>
{launchMods?.map((mod) => (
<LaunchModItem key={mod.id} {...mod}/>
@@ -35,25 +47,35 @@ export function LaunchOptionsPanel({ className, open, launchArgs, launchMods, on
)
}
export type CustomLaunchOption = {
readonly id: string;
readonly label: string;
readonly data: {
readonly command?: string;
}
}
export type LaunchModItemProps = {
readonly id: LaunchMod;
readonly id: string;
readonly icon?: SvgIcon;
readonly label: string;
readonly description: string;
readonly description?: string;
readonly active: boolean;
readonly visible?: boolean;
readonly pinned?: boolean;
readonly onChange?: (val: boolean) => void;
readonly onPinChange?: (val: boolean) => void;
readonly onEdit?: () => void;
readonly onDelete?: () => void;
}
export function LaunchModItem({ id, icon: Icon, label, description, active, visible, pinned, onChange, onPinChange }: LaunchModItemProps) {
export function LaunchModItem({ id, icon: Icon, label, description, active, visible, pinned, onChange, onPinChange, onEdit, onDelete }: LaunchModItemProps) {
const { text: t } = useTranslationV2();
return (
<Tippy theme="default" className="break-words" placement="top" content={description}>
<button id={id} className={cn("grow rounded-md bg-theme-1 relative flex justify-center items-center h-10 py-1 px-3", visible === false && "hidden")} onClick={e => { e.preventDefault(); e.stopPropagation(); onChange?.(!active) }}>
<Tippy theme="default" className="break-words" placement="top" content={description ?? null} disabled={!description}>
<div id={id} className={cn("grow rounded-md bg-theme-1 relative flex justify-center items-center h-10 py-1 px-3", visible === false && "hidden")} onClick={e => { e.preventDefault(); e.stopPropagation(); onChange?.(!active) }}>
<BsmCheckbox className="h-4 aspect-square z-[1] relative mr-1.5" checked={active} onChange={onChange}/>
{Icon && <Icon className="h-full w-fit py-0.5 mr-1.5 text-gray-800 dark:text-gray-200"/>}
<span className="font-bold text-gray-800 dark:text-gray-200">{label}</span>
@@ -66,7 +88,21 @@ export function LaunchModItem({ id, icon: Icon, label, description, active, visi
</button>
</Tippy>
)}
</button>
{onEdit && (
<Tippy theme="default" placement="right" content={t("misc.edit")} hideOnClick>
<button className="h-full py-2 px-1" onClick={e => { e.preventDefault(); e.stopPropagation(); onEdit?.() }}>
<EditIcon className="size-full text-gray-800 dark:text-gray-200"/>
</button>
</Tippy>
)}
{onDelete && (
<Tippy theme="default" placement="right" content={t("misc.delete")} hideOnClick>
<button className="h-full py-2 px-1" onClick={e => { e.preventDefault(); e.stopPropagation(); onDelete?.() }}>
<TrashIcon className="size-full text-gray-800 dark:text-gray-200"/>
</button>
</Tippy>
)}
</div>
</Tippy>
);
@@ -18,13 +18,15 @@ import { BSVersionManagerService } from "renderer/services/bs-version-manager.se
import { safeLt } from "shared/helpers/semver.helpers";
import { WarningIcon } from "renderer/components/svgs/icons/warning-icon.component";
import Tippy from "@tippyjs/react";
import { LaunchModItemProps, LaunchOptionsPanel } from "./launch-options-panel.component";
import { LaunchMod, LaunchMods } from "shared/models/bs-launch/launch-option.interface";
import { CustomLaunchOption, LaunchModItemProps, LaunchOptionsPanel } from "./launch-options-panel.component";
import { LaunchMods } from "shared/models/bs-launch/launch-option.interface";
import { OculusIcon } from "renderer/components/svgs/icons/oculus-icon.component";
import { DesktopIcon } from "renderer/components/svgs/icons/desktop-icon.component";
import { TerminalIcon } from "renderer/components/svgs/icons/terminal-icon.component";
import { DefaultConfigKey } from "renderer/config/default-configuration.config";
import { EditIcon } from "renderer/components/svgs/icons/edit-icon.component";
import { ModalExitCode, ModalService } from "renderer/services/modale.service";
import { CreateCustomLaunchOptionModal } from "renderer/components/modal/modal-types/create-custom-launch-option.component";
type Props = { version: BSVersion };
@@ -35,12 +37,15 @@ export function LaunchSlide({ version }: Props) {
const bsLauncherService = useService(BSLauncherService);
const bsDownloader = useService(BsDownloaderService);
const versions = useService(BSVersionManagerService);
const modal = useService(ModalService);
const [advancedLaunch, setAdvancedLaunch] = useState(false);
const [command, setCommand] = useState<string>(configService.get<string>("launch-command") || "");
const customLaunchOptions = useObservable<CustomLaunchOption[]>(() => configService.watch<CustomLaunchOption[]>("custom-launch-options"), []);
const [customLaunchModsArgs, setCustomLaunchModsArgs] = useState<string[]>([]);
const versionDownloading = useObservable(() => bsDownloader.downloadingVersion$);
const [activeLaunchMods, setActiveLaunchMods] = useState<LaunchMod[]>(configService.get("launch-mods") ?? []);
const [pinnedLaunchMods, setPinnedLaunchMods] = useState<LaunchMod[]>(configService.get("pinned-launch-mods" as DefaultConfigKey) ?? []);
const [activeLaunchMods, setActiveLaunchMods] = useState<string[]>(configService.get("launch-mods") ?? []);
const [pinnedLaunchMods, setPinnedLaunchMods] = useState<string[]>(configService.get("pinned-launch-mods" as DefaultConfigKey) ?? []);
const versionRunning = useObservable(() => bsLauncherService.versionRunning$);
@@ -60,11 +65,11 @@ export function LaunchSlide({ version }: Props) {
}
}, [activeLaunchMods]);
const toggleActiveLaunchMod = (checked: boolean, launchMod: LaunchMod) => checked
const toggleActiveLaunchMod = (checked: boolean, launchMod: string) => checked
? setActiveLaunchMods(prev => [...prev, launchMod])
: setActiveLaunchMods(prev => prev.filter(mod => mod !== launchMod));
const togglePinnedLaunchMod = (pinned: boolean, launchMod: LaunchMod) => pinned
const togglePinnedLaunchMod = (pinned: boolean, launchMod: string) => pinned
? setPinnedLaunchMods(prev => [...prev, launchMod])
: setPinnedLaunchMods(prev => prev.filter(mod => mod !== launchMod));
@@ -77,6 +82,29 @@ export function LaunchSlide({ version }: Props) {
: ["BSInstances", version.name, "Logs"];
}
const customOptions = customLaunchOptions?.map<LaunchModItemProps>(option => ({
id: option.id,
label: option.label,
active: activeLaunchMods.includes(option.id),
pinned: pinnedLaunchMods.includes(option.id),
onChange: (checked) => {
toggleActiveLaunchMod(checked, option.id)
setCustomLaunchModsArgs(prev => {
if(checked){
return [...prev, option.data.command];
}
return prev.filter(arg => arg !== option.data.command);
});
},
onPinChange: (pinned) => togglePinnedLaunchMod(pinned, option.id),
onEdit: () => {
saveCustomLaunchOption(option);
},
onDelete: () => {
deleteCustomLaunchOption(option.id);
},
})) ?? [];
return [
{
id: LaunchMods.OCULUS,
@@ -141,14 +169,15 @@ export function LaunchSlide({ version }: Props) {
onChange: (checked) => toggleActiveLaunchMod(checked, LaunchMods.PROTON_LOGS),
onPinChange: (pinned) => togglePinnedLaunchMod(pinned, LaunchMods.PROTON_LOGS),
},
...customOptions,
]
}, [activeLaunchMods, pinnedLaunchMods, version]);
}, [activeLaunchMods, pinnedLaunchMods, customLaunchOptions, version]);
const launch = async () => {
const launch$ = bsLauncherService.launch({
version,
launchMods: activeLaunchMods,
command,
launchMods: activeLaunchMods.map(mod => LaunchMods[mod as keyof typeof LaunchMods]).filter(Boolean),
command: [command, ...customLaunchModsArgs].filter(Boolean).join(" ").trim(),
});
return lastValueFrom(launch$).catch(() => {});
@@ -158,6 +187,19 @@ export function LaunchSlide({ version }: Props) {
return safeLt(version?.BSVersion, versions.getRecommendedVersion()?.BSVersion);
}, [version]);
const saveCustomLaunchOption = async (option: Partial<CustomLaunchOption>) => {
const result = await modal.openModal(CreateCustomLaunchOptionModal, { data: option });
if(result.exitCode !== ModalExitCode.COMPLETED) { return; }
const newCustomLaunchOptions = (customLaunchOptions ?? []).filter(mode => mode.id !== result.data.id);
newCustomLaunchOptions.push(result.data);
configService.set("custom-launch-options", newCustomLaunchOptions);
}
const deleteCustomLaunchOption = (id: string) => {
const newCustomLaunchOptions = (customLaunchOptions ?? []).filter(mode => mode.id !== id);
configService.set("custom-launch-options", newCustomLaunchOptions);
}
return (
<div className="w-full shrink-0 items-center relative flex flex-col justify-start overflow-hidden">
<div className="flex flex-col gap-3 justify-center items-center mb-4">
@@ -204,7 +246,7 @@ export function LaunchSlide({ version }: Props) {
/>
</div>
</div>
<LaunchOptionsPanel open={advancedLaunch} launchMods={launchModItems} launchArgs={command} className="w-full max-w-3xl mt-3" onLaunchArgsChange={setCommand}/>
<LaunchOptionsPanel open={advancedLaunch} launchMods={launchModItems} launchArgs={command} className="w-full max-w-3xl mt-3" onLaunchArgsChange={setCommand} onAddLaunchMod={command => saveCustomLaunchOption({ data: { command } })}/>
<div className='grow flex justify-center items-center p-2'>
<BsmButton
onClick={launch}
@@ -29,7 +29,7 @@ export class ConfigurationService {
public get<Type>(key: string | DefaultConfigKey): Type {
const rawValue = (window.sessionStorage.getItem(key) ?? window.localStorage.getItem(key));
const tryParse = tryit<Type>(() => JSON.parse(rawValue));
const tryParse = tryit(() => JSON.parse(rawValue));
const res = (tryParse.error ? rawValue : tryParse.result) as Type;
@@ -64,4 +64,6 @@ export class ConfigurationService {
this.observers.set(key, new BehaviorSubject(this.get(key)));
return this.observers.get(key).asObservable() as Observable<T>;
}
}