feat: launch options

This commit is contained in:
DecDuck
2025-04-27 21:07:39 +10:00
parent 4941f2a6fa
commit 0f717d51d0
12 changed files with 391 additions and 73 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "drop-base"]
path = drop-base
url = https://github.com/drop-oss/drop-base

View File

@ -0,0 +1,31 @@
<template>
<div>
<label for="launch" class="block text-sm/6 font-medium text-zinc-100"
>Launch string template</label
>
<div class="mt-2">
<input
type="text"
name="launch"
id="launch"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-zinc-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
placeholder="{}"
aria-describedby="launch-description"
v-model="model!!.launchString"
/>
</div>
<p class="mt-2 text-sm text-zinc-400" id="launch-description">
Override the launch string. Passed to system's default shell, and replaces
"{}" with the command to start the game.
<span class="font-semibold text-zinc-200"
>Leaving it blank will cause the game not to start.</span
>
</p>
</div>
</template>
<script setup lang="ts">
import type { FrontendGameConfiguration } from "~/composables/game";
const model = defineModel<FrontendGameConfiguration>();
</script>

View File

@ -0,0 +1,122 @@
<template>
<ModalTemplate size-class="max-w-4xl" v-model="open">
<template #default>
<div class="flex flex-row gap-x-4">
<nav class="flex flex-1 flex-col" aria-label="Sidebar">
<ul role="list" class="-mx-2 space-y-1">
<li v-for="(tab, tabIdx) in tabs" :key="tab.name">
<button
@click="() => (currentTabIndex = tabIdx)"
:class="[
tabIdx == currentTabIndex
? 'bg-zinc-800 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
'transition w-full group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
]"
>
<component
:is="tab.icon"
:class="[
tabIdx == currentTabIndex
? 'text-zinc-100'
: 'text-gray-400 group-hover:text-zinc-100',
'size-6 shrink-0',
]"
aria-hidden="true"
/>
{{ tab.name }}
</button>
</li>
</ul>
</nav>
<div class="border-l-2 border-zinc-800 w-full grow pl-4">
<component
v-model="configuration"
:is="tabs[currentTabIndex]?.page"
/>
</div>
</div>
<div v-if="saveError" class="mt-5 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ saveError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
@click="() => save()"
:loading="saveLoading"
type="submit"
class="ml-2 w-full sm:w-fit"
>
Save
</LoadingButton>
<button
@click="() => (open = false)"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { Component } from "vue";
import {
RocketLaunchIcon,
ServerIcon,
TrashIcon,
XCircleIcon,
} from "@heroicons/vue/20/solid";
import Launch from "./GameOptions/Launch.vue";
import type { FrontendGameConfiguration } from "~/composables/game";
import { invoke } from "@tauri-apps/api/core";
const open = defineModel<boolean>();
const props = defineProps<{ gameId: string }>();
const game = await useGame(props.gameId);
const configuration: Ref<FrontendGameConfiguration> = ref({
launchString: game.version!!.launchCommandTemplate,
});
const tabs: Array<{ name: string; icon: Component; page: Component }> = [
{
name: "Launch",
icon: RocketLaunchIcon,
page: Launch,
},
{
name: "Storage",
icon: ServerIcon,
page: h("div"),
},
];
const currentTabIndex = ref(0);
const saveLoading = ref(false);
const saveError = ref<undefined | string>();
async function save() {
saveLoading.value = true;
try {
await invoke("update_game_configuration", {
gameId: game.game.id,
options: configuration.value,
});
open.value = false;
} catch (e) {
saveError.value = (e as unknown as string).toString();
}
saveLoading.value = false;
}
</script>

View File

@ -1,33 +1,75 @@
<template>
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
<div class="inline-flex divide-x divide-zinc-900">
<button type="button" @click="() => buttonActions[props.status.type]()" :class="[
styles[props.status.type],
showDropdown ? 'rounded-l-md' : 'rounded-md',
'inline-flex uppercase font-display items-center gap-x-2 px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]">
<component :is="buttonIcons[props.status.type]" class="-mr-0.5 size-5" aria-hidden="true" />
<button
type="button"
@click="() => buttonActions[props.status.type]()"
:class="[
styles[props.status.type],
showDropdown ? 'rounded-l-md' : 'rounded-md',
'inline-flex uppercase font-display items-center gap-x-2 px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<component
:is="buttonIcons[props.status.type]"
class="-mr-0.5 size-5"
aria-hidden="true"
/>
{{ buttonNames[props.status.type] }}
</button>
<Menu v-if="showDropdown" as="div" class="relative inline-block text-left grow">
<Menu
v-if="showDropdown"
as="div"
class="relative inline-block text-left grow"
>
<div class="h-full">
<MenuButton :class="[
styles[props.status.type],
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm group',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2'
]">
<MenuButton
:class="[
styles[props.status.type],
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm group',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<ChevronDownIcon class="size-5" aria-hidden="true" />
</MenuButton>
</div>
<transition enter-active-class="transition ease-out duration-100" enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100" leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95">
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-50 mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none">
class="absolute right-0 z-[500] mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none"
>
<div class="py-1">
<MenuItem v-slot="{ active }">
<button @click="() => emit('uninstall')"
:class="[active ? 'bg-zinc-800 text-zinc-100 outline-none' : 'text-zinc-400', 'w-full block px-4 py-2 text-sm inline-flex justify-between']">
<button
@click="() => emit('options')"
:class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full block px-4 py-2 text-sm inline-flex justify-between',
]"
>
Options
<Cog6ToothIcon class="size-5" />
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button
@click="() => emit('uninstall')"
:class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full block px-4 py-2 text-sm inline-flex justify-between',
]"
>
Uninstall
<TrashIcon class="size-5" />
</button>
@ -45,13 +87,13 @@ import {
ChevronDownIcon,
PlayIcon,
QueueListIcon,
TrashIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import { GameStatusEnum, type GameStatus } from "~/types.js";
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { Cog6ToothIcon, TrashIcon } from "@heroicons/vue/24/outline";
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
@ -60,19 +102,32 @@ const emit = defineEmits<{
(e: "queue"): void;
(e: "uninstall"): void;
(e: "kill"): void;
(e: "options"): void;
}>();
const showDropdown = computed(() => props.status.type === GameStatusEnum.Installed || props.status.type === GameStatusEnum.SetupRequired);
const showDropdown = computed(
() =>
props.status.type === GameStatusEnum.Installed ||
props.status.type === GameStatusEnum.SetupRequired
);
const styles: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]: "bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
[GameStatusEnum.Queued]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Downloading]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.SetupRequired]: "bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600 hover:bg-yellow-500",
[GameStatusEnum.Installed]: "bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600 hover:bg-green-500",
[GameStatusEnum.Updating]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Uninstalling]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Running]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700"
[GameStatusEnum.Remote]:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
[GameStatusEnum.Queued]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Downloading]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.SetupRequired]:
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600 hover:bg-yellow-500",
[GameStatusEnum.Installed]:
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600 hover:bg-green-500",
[GameStatusEnum.Updating]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Uninstalling]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Running]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
};
const buttonNames: { [key in GameStatusEnum]: string } = {
@ -83,7 +138,7 @@ const buttonNames: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "Play",
[GameStatusEnum.Updating]: "Updating",
[GameStatusEnum.Uninstalling]: "Uninstalling",
[GameStatusEnum.Running]: "Stop"
[GameStatusEnum.Running]: "Stop",
};
const buttonIcons: { [key in GameStatusEnum]: Component } = {
@ -94,7 +149,7 @@ const buttonIcons: { [key in GameStatusEnum]: Component } = {
[GameStatusEnum.Installed]: PlayIcon,
[GameStatusEnum.Updating]: ArrowDownTrayIcon,
[GameStatusEnum.Uninstalling]: TrashIcon,
[GameStatusEnum.Running]: PlayIcon
[GameStatusEnum.Running]: PlayIcon,
};
const buttonActions: { [key in GameStatusEnum]: () => void } = {
@ -104,7 +159,7 @@ const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.SetupRequired]: () => emit("launch"),
[GameStatusEnum.Installed]: () => emit("launch"),
[GameStatusEnum.Updating]: () => emit("queue"),
[GameStatusEnum.Uninstalling]: () => { },
[GameStatusEnum.Running]: () => emit("kill")
[GameStatusEnum.Uninstalling]: () => {},
[GameStatusEnum.Running]: () => emit("kill"),
};
</script>

View File

@ -1,8 +1,9 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Game, GameStatus, GameStatusEnum } from "~/types";
import type { Game, GameStatus, GameStatusEnum, GameVersion } from "~/types";
const gameRegistry: { [key: string]: Game } = {};
const gameRegistry: { [key: string]: { game: Game; version?: GameVersion } } =
{};
const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
@ -31,13 +32,14 @@ export const parseStatus = (status: SerializedGameStatus): GameStatus => {
export const useGame = async (gameId: string) => {
if (!gameRegistry[gameId]) {
const data: { game: Game; status: SerializedGameStatus } = await invoke(
"fetch_game",
{
gameId,
}
);
gameRegistry[gameId] = data.game;
const data: {
game: Game;
status: SerializedGameStatus;
version?: GameVersion;
} = await invoke("fetch_game", {
gameId,
});
gameRegistry[gameId] = { game: data.game, version: data.version };
if (!gameStatusRegistry[gameId]) {
gameStatusRegistry[gameId] = ref(parseStatus(data.status));
@ -53,5 +55,9 @@ export const useGame = async (gameId: string) => {
const game = gameRegistry[gameId];
const status = gameStatusRegistry[gameId];
return { game, status };
};
return { ...game, status };
};
export type FrontendGameConfiguration = {
launchString: string;
};

1
drop-base Submodule

Submodule drop-base added at 26698e5b06

View File

@ -13,5 +13,5 @@ export default defineNuxtConfig({
ssr: false,
extends: [["github:drop-oss/drop-base"]],
extends: [["./drop-base"]],
});

View File

@ -24,18 +24,16 @@
</h1>
<div class="flex flex-row gap-x-4 items-stretch mb-8">
<div
class="transition-transform duration-300 hover:scale-105 active:scale-95 shadow-xl"
>
<GameStatusButton
@install="() => installFlow()"
@launch="() => launch()"
@queue="() => queue()"
@uninstall="() => uninstall()"
@kill="() => kill()"
:status="status"
/>
</div>
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
<GameStatusButton
@install="() => installFlow()"
@launch="() => launch()"
@queue="() => queue()"
@uninstall="() => uninstall()"
@kill="() => kill()"
@options="() => (configureModalOpen = true)"
:status="status"
/>
<a
:href="remoteUrl"
target="_blank"
@ -371,6 +369,8 @@
</template>
</ModalTemplate>
<GameOptionsModal v-model="configureModalOpen" :game-id="game.id" />
<Transition
enter="transition ease-out duration-300"
enter-from="opacity-0"
@ -463,7 +463,6 @@ import {
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { invoke } from "@tauri-apps/api/core";
import { GameStatusEnum } from "~/types";
import { micromark } from "micromark";
const route = useRoute();
@ -493,6 +492,8 @@ const versionOptions = ref<
const installDirs = ref<undefined | Array<string>>();
const currentImageIndex = ref(0);
const configureModalOpen = ref(false);
async function installFlow() {
installFlowOpen.value = true;
versionOptions.value = undefined;

View File

@ -16,12 +16,13 @@ use crate::games::state::{GameStatusManager, GameStatusWithTransient};
use crate::remote::auth::generate_authorization_header;
use crate::remote::cache::{cache_object, get_cached_object, get_cached_object_db};
use crate::remote::requests::make_request;
use crate::AppState;
use crate::{AppState, DB};
#[derive(Serialize, Deserialize)]
pub struct FetchGameStruct {
game: Game,
status: GameStatusWithTransient,
version: Option<GameVersion>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
@ -140,6 +141,24 @@ pub fn fetch_game_logic(
) -> Result<FetchGameStruct, RemoteAccessError> {
let mut state_handle = state.lock().unwrap();
let handle = DB.borrow_data().unwrap();
let metadata_option = handle.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => Some(
handle
.applications
.game_versions
.get(&metadata.id)
.unwrap()
.get(metadata.version.as_ref().unwrap())
.unwrap()
.clone(),
),
};
drop(handle);
let game = state_handle.games.get(&id);
if let Some(game) = game {
let status = GameStatusManager::fetch_state(&id);
@ -147,6 +166,7 @@ pub fn fetch_game_logic(
let data = FetchGameStruct {
game: game.clone(),
status,
version,
};
cache_object(id, game)?;
@ -185,6 +205,7 @@ pub fn fetch_game_logic(
let data = FetchGameStruct {
game: game.clone(),
status,
version,
};
cache_object(id, &game)?;
@ -196,9 +217,31 @@ pub fn fetch_game_logic_offline(
id: String,
_state: tauri::State<'_, Mutex<AppState>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
let handle = DB.borrow_data().unwrap();
let metadata_option = handle.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => Some(
handle
.applications
.game_versions
.get(&metadata.id)
.unwrap()
.get(metadata.version.as_ref().unwrap())
.unwrap()
.clone(),
),
};
drop(handle);
let status = GameStatusManager::fetch_state(&id);
let game = get_cached_object::<String, Game>(id)?;
Ok(FetchGameStruct { game, status })
Ok(FetchGameStruct {
game,
status,
version,
})
}
pub fn fetch_game_verion_options_logic(
@ -401,3 +444,54 @@ pub fn push_game_update(app_handle: &AppHandle, game_id: &String, status: GameSt
)
.unwrap();
}
// TODO @quexeky fix error types (I used String lmao)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendGameOptions {
launch_string: String,
}
#[tauri::command]
pub fn update_game_configuration(
game_id: String,
options: FrontendGameOptions,
) -> Result<(), String> {
let mut handle = DB.borrow_data_mut().unwrap();
let installed_version = handle
.applications
.installed_game_version
.get(&game_id)
.ok_or("Game not installed")?;
let id = installed_version.id.clone();
let version = installed_version.version.clone().unwrap();
let mut existing_configuration = handle
.applications
.game_versions
.get(&id)
.unwrap()
.get(&version)
.unwrap()
.clone();
// Add more options in here
existing_configuration.launch_command_template = options.launch_string;
// Add no more options past here
handle
.applications
.game_versions
.get_mut(&id)
.unwrap()
.insert(version.to_string(), existing_configuration);
drop(handle);
DB.save().map_err(|e| e.to_string())?;
Ok(())
}

View File

@ -33,7 +33,7 @@ use games::commands::{
fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game,
};
use games::downloads::commands::download_game;
use games::library::Game;
use games::library::{update_game_configuration, Game};
use http::Response;
use http::{header::*, response::Builder as ResponseBuilder};
use log::{debug, info, warn, LevelFilter};
@ -255,6 +255,7 @@ pub fn run() {
fetch_download_dir_stats,
fetch_game_status,
fetch_game_verion_options,
update_game_configuration,
// Collections
fetch_collections,
fetch_collection,

View File

@ -266,13 +266,13 @@ impl ProcessManager<'_> {
.map_err(|e| ProcessError::FormatError(e.to_string()))?
.to_string();
info!("launching process {} in {}", launch_string, install_dir);
#[cfg(target_os = "windows")]
let mut command = Command::new("cmd");
#[cfg(target_os = "windows")]
command.args(["/C", &launch_string]);
info!("launching (in {}): {}", install_dir, launch_string,);
#[cfg(unix)]
let mut command: Command = Command::new("sh");
#[cfg(unix)]

View File

@ -37,6 +37,10 @@ export type Game = {
mImageCarousel: string[];
};
export type GameVersion = {
launchCommandTemplate: string;
};
export enum AppStatus {
NotConfigured = "NotConfigured",
Offline = "Offline",
@ -54,7 +58,7 @@ export enum GameStatusEnum {
Updating = "Updating",
Uninstalling = "Uninstalling",
SetupRequired = "SetupRequired",
Running = "Running"
Running = "Running",
}
export type GameStatus = {
@ -66,17 +70,17 @@ export enum DownloadableType {
Game = "Game",
Tool = "Tool",
DLC = "DLC",
Mod = "Mod"
Mod = "Mod",
}
export type DownloadableMetadata = {
id: string,
version: string,
downloadType: DownloadableType
}
id: string;
version: string;
downloadType: DownloadableType;
};
export type Settings = {
autostart: boolean,
maxDownloadThreads: number,
forceOffline: boolean
}
autostart: boolean;
maxDownloadThreads: number;
forceOffline: boolean;
};