Game updates (#187)

* refactor: split umu launcher

* feat: latest version picker + fixes

* feat: frontend latest changes

* feat: game update detection w/ setting

* feat: fixes and refactor for game update

* fix: windows ui

* fix: deps

* feat: update modifications

* feat: missing ui and lock update

* fix: create install dir on init

* fix: clippy

* fix: clippy x2

* feat: add configuration option to toggle updates

* feat: uninstall dropdown on partiallyinstalled
This commit is contained in:
DecDuck
2026-02-25 23:27:30 +11:00
committed by GitHub
parent fe9a88b9b2
commit 4728ea177c
38 changed files with 1193 additions and 573 deletions
+10 -9
View File
@@ -11,7 +11,7 @@
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"
v-model="model.launchTemplate"
/>
</div>
<p class="mt-2 text-sm text-zinc-400" id="launch-description">
@@ -129,9 +129,9 @@
</span>
</li>
</ListboxOption>
<li v-else class="italic text-zinc-400 py-2 pr-9 pl-3"
>No auto-discovered layers.</li
>
<li v-else class="italic text-zinc-400 py-2 pr-9 pl-3">
No auto-discovered layers.
</li>
<h1 class="text-white text-sm font-semibold bg-zinc-900 py-2 px-2">
Manually added
</h1>
@@ -170,9 +170,9 @@
</span>
</li>
</ListboxOption>
<li v-else class="italic text-zinc-400 py-2 pr-9 pl-3"
>No manually added layers.</li
>
<li v-else class="italic text-zinc-400 py-2 pr-9 pl-3">
No manually added layers.
</li>
</ListboxOptions>
</transition>
</div>
@@ -190,7 +190,7 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import type { FrontendGameConfiguration, ProtonPath } from "~/composables/game";
import type { ProtonPath } from "~/composables/game";
import {
Listbox,
ListboxButton,
@@ -201,8 +201,9 @@ import {
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { CheckIcon } from "@heroicons/vue/20/solid";
import { WrenchIcon } from "@heroicons/vue/24/solid";
import type { GameVersion } from "~/types";
const model = defineModel<FrontendGameConfiguration>({ required: true });
const model = defineModel<GameVersion["userConfiguration"]>({ required: true });
const props = defineProps<{
protonEnabled: boolean;
+36
View File
@@ -0,0 +1,36 @@
<template>
<div class="space-y-8">
<div class="flex flex-row items-center justify-between">
<div>
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Enable update checks
</h3>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Drop will automatically check for updates from your server
</p>
</div>
<Switch
v-model="model.enableUpdates"
:class="[
model.enableUpdates ? 'bg-blue-600' : 'bg-zinc-700',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
]"
>
<span
:class="[
model.enableUpdates ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</div>
</div>
</template>
<script setup lang="ts">
import { Switch } from "@headlessui/vue";
import type { GameVersion } from '~/types';
const model = defineModel<GameVersion["userConfiguration"]>({ required: true });
</script>
+16 -10
View File
@@ -1,7 +1,7 @@
<template>
<ModalTemplate size-class="max-w-4xl" v-model="open">
<template #default>
<div class="flex flex-row gap-x-4">
<div class="flex flex-row gap-x-4 h-96">
<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">
@@ -29,7 +29,7 @@
</li>
</ul>
</nav>
<div class="border-l-2 border-zinc-800 w-full grow pl-4">
<div class="border-l-2 border-zinc-800 w-full grow pl-4 overflow-y-scroll">
<component
v-model="configuration"
:is="tabs[currentTabIndex]?.page"
@@ -80,8 +80,10 @@ import {
XCircleIcon,
} from "@heroicons/vue/20/solid";
import Launch from "./GameOptions/Launch.vue";
import type { FrontendGameConfiguration } from "~/composables/game";
import Updates from "./GameOptions/Updates.vue";
import { invoke } from "@tauri-apps/api/core";
import { ArrowPathIcon } from "@heroicons/vue/24/solid";
import type { GameVersion } from "~/types";
const appState = useAppState();
@@ -89,17 +91,16 @@ const open = defineModel<boolean>();
const props = defineProps<{ gameId: string }>();
const game = await useGame(props.gameId);
const configuration: Ref<FrontendGameConfiguration> = ref({
launchString: game.version!.userConfiguration.launchTemplate,
overrideProtonPath: game.version!.userConfiguration.overrideProtonPath,
});
const configuration: Ref<GameVersion["userConfiguration"]> = ref(game.version.value!.userConfiguration);
const hasWindows = !!(
game.version!.setups.find((v) => v.platform === "Windows") ??
game.version!.launches.find((v) => v.platform === "Windows")
game.version.value!.setups.find((v) => v.platform === "Windows") ??
game.version.value!.launches.find((v) => v.platform === "Windows")
);
const protonEnabled = !!(appState.value!.umuState !== "NotNeeded" && hasWindows);
const protonEnabled = !!(
appState.value!.umuState !== "NotNeeded" && hasWindows
);
const tabs: Array<{ name: string; icon: Component; page: Component }> = [
{
@@ -107,6 +108,11 @@ const tabs: Array<{ name: string; icon: Component; page: Component }> = [
icon: RocketLaunchIcon,
page: Launch,
},
{
name: "Updates",
icon: ArrowPathIcon,
page: Updates,
},
{
name: "Storage",
icon: ServerIcon,
+111 -62
View File
@@ -3,19 +3,19 @@
<div class="inline-flex divide-x divide-zinc-900">
<button
type="button"
@click="() => buttonActions[props.status.type]()"
@click="() => fetchStatusStyleData($props.status).action()"
:class="[
styles[props.status.type],
fetchStatusStyleData($props.status).style,
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]"
:is="fetchStatusStyleData($props.status).icon"
class="-mr-0.5 size-5"
aria-hidden="true"
/>
{{ buttonNames[props.status.type] }}
{{ fetchStatusStyleData($props.status).buttonName }}
</button>
<Menu
v-if="showDropdown"
@@ -25,7 +25,7 @@
<div class="h-full">
<MenuButton
:class="[
styles[props.status.type],
fetchStatusStyleData($props.status).style,
'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',
]"
@@ -46,6 +46,21 @@
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('install')"
:class="[
active
? 'bg-zinc-800 text-zinc-100 outline-none'
: 'text-zinc-400',
'w-full px-4 py-2 text-sm inline-flex justify-between',
]"
>
Install
<ArrowDownTrayIcon class="size-5" />
</button>
</MenuItem>
<MenuItem v-if="showOptions" v-slot="{ active }">
<button
@click="() => emit('options')"
@@ -53,7 +68,7 @@
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',
'w-full px-4 py-2 text-sm inline-flex justify-between',
]"
>
Options
@@ -67,7 +82,7 @@
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',
'w-full inline-flex px-4 py-2 text-sm justify-between',
]"
>
Uninstall
@@ -93,9 +108,14 @@ import {
} from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import { GameStatusEnum, type GameStatus } from "~/types.js";
import {
type EmptyGameStatusEnum,
InstalledType,
type GameStatus,
} from "~/types.js";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { Cog6ToothIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { ArrowsRightLeftIcon, ArrowUpTrayIcon } from "@heroicons/vue/24/solid";
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
@@ -108,76 +128,105 @@ const emit = defineEmits<{
(e: "resume"): void;
}>();
const showDropdown = computed(
() =>
props.status.type === GameStatusEnum.Installed ||
props.status.type === GameStatusEnum.SetupRequired ||
props.status.type === GameStatusEnum.PartiallyInstalled
);
interface StatusStyleData {
style: string;
buttonName: string;
icon: Component;
action: () => void;
}
function fetchStatusStyleData(status: GameStatus): StatusStyleData {
if (status.type === "Installed") {
if (status.install_type.type === InstalledType.Installed) {
return {
style:
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600 hover:bg-green-500",
buttonName: "Play",
icon: PlayIcon,
action: () => emit("launch"),
};
}
if (status.install_type.type === InstalledType.SetupRequired) {
return {
style:
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600 hover:bg-yellow-500",
buttonName: "Setup",
icon: WrenchIcon,
action: () => emit("launch"),
};
}
if (status.install_type.type === InstalledType.PartiallyInstalled) {
return {
style:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
buttonName: "Resume",
icon: ArrowDownTrayIcon,
action: () => emit("resume"),
};
}
throw "Non-exhaustive install type: " + JSON.stringify(status.install_type);
}
return {
style: styles[status.type],
buttonName: buttonNames[status.type],
icon: buttonIcons[status.type],
action: buttonActions[status.type],
};
}
const showDropdown = computed(() => props.status.type === "Installed");
const showOptions = computed(
() => props.status.type === GameStatusEnum.Installed
() =>
showDropdown.value &&
props.status.type === "Installed" &&
props.status.install_type.type !== InstalledType.PartiallyInstalled,
);
const styles: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]:
const styles: { [key in EmptyGameStatusEnum]: string } = {
Remote:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
[GameStatusEnum.Queued]:
Queued:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Downloading]:
Downloading:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Validating]:
Validating:
"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]:
Updating:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Uninstalling]:
Uninstalling:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.Running]:
Running:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700 hover:bg-zinc-700",
[GameStatusEnum.PartiallyInstalled]:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 hover:bg-blue-500",
};
const buttonNames: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]: "Install",
[GameStatusEnum.Queued]: "Queued",
[GameStatusEnum.Downloading]: "Downloading",
[GameStatusEnum.Validating]: "Validating",
[GameStatusEnum.SetupRequired]: "Setup",
[GameStatusEnum.Installed]: "Play",
[GameStatusEnum.Updating]: "Updating",
[GameStatusEnum.Uninstalling]: "Uninstalling",
[GameStatusEnum.Running]: "Stop",
[GameStatusEnum.PartiallyInstalled]: "Resume",
const buttonNames: { [key in EmptyGameStatusEnum]: string } = {
Remote: "Install",
Queued: "Queued",
Downloading: "Downloading",
Validating: "Validating",
Updating: "Updating",
Uninstalling: "Uninstalling",
Running: "Stop",
};
const buttonIcons: { [key in GameStatusEnum]: Component } = {
[GameStatusEnum.Remote]: ArrowDownTrayIcon,
[GameStatusEnum.Queued]: QueueListIcon,
[GameStatusEnum.Downloading]: ArrowDownTrayIcon,
[GameStatusEnum.Validating]: ServerIcon,
[GameStatusEnum.SetupRequired]: WrenchIcon,
[GameStatusEnum.Installed]: PlayIcon,
[GameStatusEnum.Updating]: ArrowDownTrayIcon,
[GameStatusEnum.Uninstalling]: TrashIcon,
[GameStatusEnum.Running]: StopIcon,
[GameStatusEnum.PartiallyInstalled]: ArrowDownTrayIcon,
const buttonIcons: { [key in EmptyGameStatusEnum]: Component } = {
Remote: ArrowDownTrayIcon,
Queued: QueueListIcon,
Downloading: ArrowDownTrayIcon,
Validating: ServerIcon,
Updating: ArrowDownTrayIcon,
Uninstalling: TrashIcon,
Running: StopIcon,
};
const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.Remote]: () => emit("install"),
[GameStatusEnum.Queued]: () => emit("queue"),
[GameStatusEnum.Downloading]: () => emit("queue"),
[GameStatusEnum.Validating]: () => emit("queue"),
[GameStatusEnum.SetupRequired]: () => emit("launch"),
[GameStatusEnum.Installed]: () => emit("launch"),
[GameStatusEnum.Updating]: () => emit("queue"),
[GameStatusEnum.Uninstalling]: () => {},
[GameStatusEnum.Running]: () => emit("kill"),
[GameStatusEnum.PartiallyInstalled]: () => emit("resume"),
const buttonActions: { [key in EmptyGameStatusEnum]: () => void } = {
Remote: () => emit("install"),
Queued: () => emit("queue"),
Downloading: () => emit("queue"),
Validating: () => emit("queue"),
Updating: () => emit("queue"),
Uninstalling: () => {},
Running: () => emit("kill"),
};
</script>
+15 -7
View File
@@ -1,16 +1,24 @@
<template>
<NuxtLink to="/settings/compat">
<HeaderWidget
v-if="appState && appState.umuState !== 'NotNeeded'"
:problem="notInstalled"
>
<img src="/proton-logo.png" class="relative z-50 size-5 brightness-[30%]" />
<NuxtLink
v-if="onLinux"
to="/settings/compat"
>
<HeaderWidget :problem="protonError">
<img
src="/proton-logo.png"
class="relative z-50 size-5 brightness-[30%]"
/>
</HeaderWidget>
</NuxtLink>
</template>
<script setup lang="ts">
const appState = useAppState();
const onLinux = appState.value?.umuState !== "NotNeeded";
const paths = onLinux ? await useProtonPaths() : undefined;
const notInstalled = appState.value?.umuState === "NotInstalled";
const protonError = computed(
() =>
appState.value?.umuState === "NotInstalled" || !paths?.data.value.default,
);
</script>
+58 -44
View File
@@ -64,7 +64,7 @@
v-for="item in nav.items"
:key="nav.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center px-1 py-1.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
'transition-all duration-300 rounded-lg flex items-center px-1 py-0.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
currentNavigation == item.id
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: item.isInstalled.value
@@ -83,21 +83,24 @@
alt=""
/>
</div>
<div class="truncate inline-flex items-center gap-x-2">
<div class="truncate flex flex-col">
<p class="text-sm whitespace-nowrap font-display font-semibold">
{{ item.label }}
</p>
<p
class="truncate text-[10px] font-bold uppercase font-display"
:class="[
gameStatusTextStyle[games[item.id].status.value.type],
getGameStatusStyleText(games[item.id].status.value)[0],
]"
>
{{ gameStatusText[games[item.id].status.value.type] }}
{{ getGameStatusStyleText(games[item.id].status.value)[1] }}
</p>
</div>
</div>
</NuxtLink>
<span v-if="nav.items.length == 0" class="text-xs text-zinc-500 mx-auto"
>No games in this category</span
>
</DisclosurePanel>
</Disclosure>
</TransitionGroup>
@@ -138,7 +141,8 @@ import {
} from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import {
GameStatusEnum,
type EmptyGameStatusEnum,
InstalledType,
type Collection as Collection,
type Game,
type GameStatus,
@@ -147,31 +151,44 @@ import { TransitionGroup } from "vue";
import { listen } from "@tauri-apps/api/event";
// Style information
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "text-green-500",
[GameStatusEnum.Downloading]: "text-zinc-400",
[GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-blue-500",
[GameStatusEnum.Remote]: "text-zinc-700",
[GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-zinc-400",
[GameStatusEnum.Uninstalling]: "text-zinc-100",
[GameStatusEnum.SetupRequired]: "text-yellow-500",
[GameStatusEnum.PartiallyInstalled]: "text-gray-400",
const gameStatusTextStyle: { [key in EmptyGameStatusEnum]: string } = {
Downloading: "text-zinc-400",
Validating: "text-blue-300",
Running: "text-blue-500",
Remote: "text-zinc-700",
Queued: "text-zinc-400",
Updating: "text-zinc-400",
Uninstalling: "text-zinc-100",
};
const gameStatusText: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]: "Not installed",
[GameStatusEnum.Queued]: "Queued",
[GameStatusEnum.Downloading]: "Downloading...",
[GameStatusEnum.Validating]: "Validating...",
[GameStatusEnum.Installed]: "Installed",
[GameStatusEnum.Updating]: "Updating...",
[GameStatusEnum.Uninstalling]: "Uninstalling...",
[GameStatusEnum.SetupRequired]: "Setup required",
[GameStatusEnum.Running]: "Running",
[GameStatusEnum.PartiallyInstalled]: "Partially installed",
const gameStatusText: { [key in EmptyGameStatusEnum]: string } = {
Remote: "Not installed",
Queued: "Queued",
Downloading: "Downloading...",
Validating: "Validating...",
Updating: "Updating...",
Uninstalling: "Uninstalling...",
Running: "Running",
};
function getGameStatusStyleText(status: GameStatus): [string, string] {
if (status.type === "Installed") {
if (status.install_type.type === InstalledType.Installed) {
return ["text-green-500", "Installed"];
}
if (status.install_type.type === InstalledType.PartiallyInstalled) {
return ["text-gray-400", "Partially installed"];
}
if (status.install_type.type === InstalledType.SetupRequired) {
return ["text-yellow-500", "Setup required"];
}
throw (
"Non-exhaustive installed type, missing: " +
JSON.stringify(status.install_type)
);
}
return [gameStatusTextStyle[status.type], gameStatusText[status.type]];
}
const router = useRouter();
const searchQuery = ref("");
@@ -277,25 +294,22 @@ await new Promise<void>((r) => {
const navigation = computed(() =>
collections.value.map((collection) => {
const items = collection.entries
.map(({ game }) => {
const status = games[game.id].status;
const items = collection.entries.map(({ game }) => {
const status = games[game.id].status;
const isInstalled = computed(
() => status.value.type != GameStatusEnum.Remote,
);
const isInstalled = computed(() => status.value.type != "Remote");
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
icon: game.mIconObjectId,
isInstalled,
id: game.id,
type: game.type,
};
return item;
});
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
icon: game.mIconObjectId,
isInstalled,
id: game.id,
type: game.type,
};
return item;
});
return {
id: collection.id,