Depot API & executor launch (#173)

* feat: depot api downloads

* feat: frontend fixes and experimental webview store

* feat: sync downloader

* feat: cleanup and fixes

* feat: encrypted database and fixed resuming

* feat: launch option selector

* fix: autostart when no options

* fix: clippy

* fix: clippy x2

* feat: executor launch

* feat: executor launch

* feat: not installed error handling

* feat: better offline handling

* feat: dependency popup

* fix: cancelation and resuming issues

* feat: dedup by platform

* feat: new ui for additional components and fix dl manager clog

* feat: auto-queue dependencies

* feat: depot scanning and ranking

* feat: new library fetching stack

* In-app store page (Windows + macOS) (#176)

* feat: async store loading

* feat: fix overscroll behaviour

* fix: query params in server protocol

* fix: clippy
This commit is contained in:
DecDuck
2026-01-20 00:40:48 +00:00
committed by GitHub
parent 55fdaf51e1
commit fc69ae30ab
72 changed files with 3430 additions and 2732 deletions
@@ -0,0 +1,106 @@
<template>
<ModalTemplate :model-value="true">
<template #default
><div class="flex items-start gap-x-3">
<img :src="useObject(game.mIconObjectId)" class="size-12" />
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<h3 class="text-base font-semibold text-zinc-100">
Missing required dependency "{{ game.mName }}"
</h3>
<div class="mt-2">
<p class="text-sm text-zinc-400">
To launch this game, you need to have "{{ game.mName }}" ({{
version.displayName ?? version.versionPath
}}) installed.
</p>
</div>
</div>
</div>
<InstallDirectorySelector
:install-dirs="installDirs"
v-model="installDir"
/>
<div v-if="installError" class="mt-1 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">
{{ installError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
@click="() => install()"
:loading="installLoading"
:disabled="installLoading"
type="submit"
class="ml-2 w-full sm:w-fit"
>
Install
</LoadingButton>
<button
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"
@click="cancel"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const model = defineModel<{ gameId: string; versionId: string }>({
required: true,
});
const { game, status } = await useGame(model.value.gameId);
const versionOptions = await invoke<Array<VersionOption>>(
"fetch_game_version_options",
{
gameId: game.id,
}
);
const version = versionOptions.find(
(v) => v.versionId === model.value.versionId
)!;
const installDirs = await invoke<string[]>("fetch_download_dir_stats");
const installDir = ref(0);
function cancel() {
// @ts-expect-error
model.value = undefined;
}
const installError = ref<string | undefined>();
const installLoading = ref(false);
async function install() {
try {
installLoading.value = true;
await invoke("download_game", {
gameId: game.id,
versionId: model.value.versionId,
installDir: installDir.value,
targetPlatform: version.platform,
});
cancel();
} catch (error) {
installError.value = (error as string).toString();
}
installLoading.value = false;
}
</script>
@@ -0,0 +1,87 @@
<template>
<Listbox as="div" v-model="installDir">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
>Install to</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{ installDirs[installDir] }}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold text-zinc-100' : 'font-normal',
'block truncate',
]"
>{{ dir }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
<div class="text-zinc-400 text-sm mt-2">
Add more install directories in
<PageWidget to="/settings/downloads">
<WrenchIcon class="size-3" />
Settings
</PageWidget>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import {
CheckIcon,
ChevronUpDownIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
const installDir = defineModel<number>({ required: true });
const { installDirs } = defineProps<{ installDirs: string[] }>();
</script>
+61 -30
View File
@@ -27,12 +27,12 @@
</button>
</div>
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5 h-full">
<Disclosure
as="div"
v-for="(nav, navIndex) in filteredNavigation"
:key="nav.id"
class="first:pt-0 last:pb-0"
:class="['first:pt-0 last:pb-0', nav.tools ? 'mt-auto' : '']"
v-slot="{ open }"
:default-open="nav.deft"
>
@@ -43,9 +43,12 @@
<span class="text-sm font-semibold font-display">{{
nav.name
}}</span>
<span class="ml-6 flex h-7 items-center">
<PlusSmallIcon v-if="!open" class="size-6" aria-hidden="true" />
<MinusSmallIcon v-else class="size-6" aria-hidden="true" />
<span class="ml-6 relative flex size-4">
<MinusIcon class="absolute inset-0 size-4" aria-hidden="true" />
<MinusIcon
:class="[ !open ? 'rotate-90' : 'rotate-0', 'transition-all absolute inset-0 size-4']"
aria-hidden="true"
/>
</span>
</DisclosureButton>
</dt>
@@ -58,8 +61,8 @@
currentNavigation == item.id
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: item.isInstalled.value
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
]"
:href="item.route"
>
@@ -69,14 +72,12 @@
>
<img
class="size-6 object-cover bg-zinc-900 rounded transition-all duration-300 shadow-sm"
:src="icons[item.id]"
:src="useObject(item.icon)"
alt=""
/>
</div>
<div class="truncate inline-flex items-center gap-x-2">
<p
class="text-sm whitespace-nowrap font-display font-semibold"
>
<p class="text-sm whitespace-nowrap font-display font-semibold">
{{ item.label }}
</p>
<p
@@ -125,8 +126,8 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
import {
ArrowPathIcon,
MagnifyingGlassIcon,
MinusSmallIcon,
PlusSmallIcon,
MinusIcon,
PlusIcon,
} from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import {
@@ -143,7 +144,7 @@ const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "text-green-500",
[GameStatusEnum.Downloading]: "text-zinc-400",
[GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-green-500",
[GameStatusEnum.Running]: "text-blue-500",
[GameStatusEnum.Remote]: "text-zinc-700",
[GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-zinc-400",
@@ -172,49 +173,76 @@ const loading = ref(false);
const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {};
const icons: { [key: string]: string } = {};
const collections: Ref<Collection[]> = ref([]);
async function calculateGames(clearAll = false, forceRefresh = false) {
try {
await calculateGamesLogic(clearAll, forceRefresh);
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to fetch library",
description: `Drop encountered an error while fetching your library: ${e}`,
},
(_, c) => c(),
);
}
loading.value = false;
}
type FetchLibraryResponse = {
library: Game[];
collections: Collection[];
other: Game[];
};
async function calculateGamesLogic(clearAll = false, forceRefresh = false) {
if (clearAll) {
collections.value = [];
loading.value = true;
}
// If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out
const newGames = await invoke<Game[]>("fetch_library", {
hardRefresh: forceRefresh,
});
const otherCollections = await invoke<Collection[]>("fetch_collections", {
const library = await invoke<FetchLibraryResponse>("fetch_library", {
hardRefresh: forceRefresh,
});
const allGames = [
...newGames,
...otherCollections
...library.library,
...library.collections
.map((e) => e.entries)
.flat()
.map((e) => e.game),
...library.other,
].filter((v, i, a) => a.indexOf(v) === i);
for (const game of allGames) {
if (games[game.id]) continue;
games[game.id] = await useGame(game.id);
}
for (const game of allGames) {
if (icons[game.id]) continue;
icons[game.id] = await useObject(game.mIconObjectId);
}
const libraryCollection = {
id: "library",
name: "Library",
isDefault: true,
entries: newGames.map((e) => ({ gameId: e.id, game: e })),
entries: library.library.map((e) => ({ gameId: e.id, game: e })),
} satisfies Collection;
const otherCollection = {
id: "other",
name: "Tools & Launchers",
isDefault: false,
isTools: true,
entries: library.other.map((v) => ({ gameId: v.id, game: v })),
} satisfies Collection;
loading.value = false;
collections.value = [libraryCollection, ...otherCollections];
collections.value = [
libraryCollection,
...library.collections,
...(library.other.length > 0 ? [otherCollection] : []),
];
}
// Wait up to 300 ms for the library to load, otherwise
@@ -235,15 +263,17 @@ const navigation = computed(() =>
const status = games[game.id].status;
const isInstalled = computed(
() => status.value.type != GameStatusEnum.Remote
() => status.value.type != GameStatusEnum.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;
});
@@ -252,9 +282,10 @@ const navigation = computed(() =>
id: collection.id,
name: collection.name,
deft: collection.isDefault,
tools: collection.isTools ?? false,
items,
};
})
}),
);
const route = useRoute();
@@ -277,7 +308,7 @@ const filteredNavigation = computed(() => {
listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = currentNavigation.value;
await calculateGames();
await calculateGames(false, true);
if (oldNavigation !== currentNavigation.value) {
router.push("/library");
}
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
async function checkOffline() {
const isOffline = await invoke("check_online");
}
</script>
<template>
<div
<button
@click="checkOffline"
class="transition inline-flex items-center rounded-sm px-4 py-1.5 bg-zinc-900 text-sm text-zinc-400 gap-x-2"
>
<div class="relative">
@@ -13,5 +19,5 @@ import { ArrowDownTrayIcon, CloudIcon } from "@heroicons/vue/20/solid";
/>
</div>
Offline
</div>
</button>
</template>