merge(download-manager) -> 'main'

feat(downloads): Added Download Manager

See merge request drop-oss/drop-app!1
This commit is contained in:
DecDuck
2024-12-09 10:44:26 +00:00
42 changed files with 3067 additions and 892 deletions
+4 -1
View File
@@ -23,4 +23,7 @@ dist-ssr
*.sln
*.sw?
.nuxt
.output
.output
src-tauri/flamegraph.svg
src-tauri/perf*
+15
View File
@@ -0,0 +1,15 @@
# How to create Flamegraph
Run this in `src-tauri`:
```
WEBKIT_DISABLE_DMABUF_RENDERER=1 CARGO_PROFILE_RELEASE_DEBUG=true cargo flamegraph --release
```
You can leave out `WEBKIT_DISABLE_DMABUF_RENDERER=1` if you're not on NVIDIA/Linux
And then run this in the root dir:
```
yarn dev --port 1432
```
And then do what you want, and it'll create the flamegraph for you
+1 -1
View File
@@ -6,7 +6,7 @@ Drop app is the companion app for [Drop](https://github.com/Drop-OSS/drop). It u
Install dependencies with `yarn`
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use the environment variable in `.env`
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh`
To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`:
+4
View File
@@ -5,7 +5,11 @@
</template>
<script setup lang="ts">
import "~/composables/queue";
import { invoke } from "@tauri-apps/api/core";
import { AppStatus } from "~/types";
import { listen } from "@tauri-apps/api/event";
import { useAppState } from "./composables/app-state.js";
import {
initialNavigation,
+81
View File
@@ -0,0 +1,81 @@
<template>
<button
type="button"
@click="() => buttonActions[props.status.type]()"
:class="[
styles[props.status.type],
'inline-flex uppercase font-display items-center gap-x-2 rounded-md 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>
</template>
<script setup lang="ts">
import {
ArrowDownTrayIcon,
PlayIcon,
QueueListIcon,
TrashIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import { GameStatusEnum, type GameStatus } from "~/types.js";
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
(e: "install"): void;
(e: "cancel"): void;
(e: "play"): void;
}>();
const styles: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600",
[GameStatusEnum.Queued]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.Downloading]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.SetupRequired]:
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600",
[GameStatusEnum.Installed]:
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600",
[GameStatusEnum.Updating]: "",
[GameStatusEnum.Uninstalling]: "",
};
const buttonNames: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]: "Install",
[GameStatusEnum.Queued]: "Queued",
[GameStatusEnum.Downloading]: "Downloading",
[GameStatusEnum.SetupRequired]: "Setup",
[GameStatusEnum.Installed]: "Play",
[GameStatusEnum.Updating]: "Updating",
[GameStatusEnum.Uninstalling]: "Uninstalling",
};
const buttonIcons: { [key in GameStatusEnum]: Component } = {
[GameStatusEnum.Remote]: ArrowDownTrayIcon,
[GameStatusEnum.Queued]: QueueListIcon,
[GameStatusEnum.Downloading]: ArrowDownTrayIcon,
[GameStatusEnum.SetupRequired]: WrenchIcon,
[GameStatusEnum.Installed]: PlayIcon,
[GameStatusEnum.Updating]: ArrowDownTrayIcon,
[GameStatusEnum.Uninstalling]: TrashIcon,
};
const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.Remote]: () => emit("install"),
[GameStatusEnum.Queued]: () => emit("cancel"),
[GameStatusEnum.Downloading]: () => emit("cancel"),
[GameStatusEnum.SetupRequired]: () => {},
[GameStatusEnum.Installed]: () => emit("play"),
[GameStatusEnum.Updating]: () => emit("cancel"),
[GameStatusEnum.Uninstalling]: () => {},
};
</script>
+25 -20
View File
@@ -1,53 +1,57 @@
<template>
<div
class="h-16 bg-zinc-950 flex flex-row justify-between"
>
<div class="h-16 bg-zinc-950 flex flex-row justify-between">
<div class="flex flex-row grow items-center pl-5 pr-2 py-3">
<div class="inline-flex items-center gap-x-10">
<NuxtLink to="/store">
<Wordmark class="h-8 mb-0.5"/>
<Wordmark class="h-8 mb-0.5" />
</NuxtLink>
<nav class="inline-flex items-center mt-0.5">
<ol class="inline-flex items-center gap-x-6">
<NuxtLink
v-for="(nav, navIdx) in navigation"
:class="[
v-for="(nav, navIdx) in navigation"
:class="[
'transition uppercase font-display font-semibold text-md',
navIdx === currentPageIndex
? 'text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200',
]"
:href="nav.route"
:href="nav.route"
>
{{ nav.label }}
</NuxtLink>
</ol>
</nav>
</div>
<div @mousedown="() => window.startDragging()" class="flex cursor-pointer grow h-full" />
<div
@mousedown="() => window.startDragging()"
class="flex cursor-pointer grow h-full"
/>
<div class="inline-flex items-center">
<ol class="inline-flex gap-3">
<HeaderQueueWidget
:object="currentQueueObject"
/>
<li v-for="(item, itemIdx) in quickActions">
<HeaderWidget
@click="item.action"
:notifications="item.notifications"
@click="item.action"
:notifications="item.notifications"
>
<component class="h-5" :is="item.icon"/>
<component class="h-5" :is="item.icon" />
</HeaderWidget>
</li>
<HeaderUserWidget/>
<HeaderUserWidget />
</ol>
</div>
</div>
<WindowControl class="h-16 w-16 p-4"/>
<WindowControl class="h-16 w-16 p-4" />
</div>
</template>
<script setup lang="ts">
import {BellIcon, UserGroupIcon} from "@heroicons/vue/16/solid";
import type {NavigationItem, QuickActionNav} from "../types";
import { BellIcon, UserGroupIcon } from "@heroicons/vue/16/solid";
import type { NavigationItem, QuickActionNav } from "../types";
import HeaderWidget from "./HeaderWidget.vue";
import {getCurrentWindow} from "@tauri-apps/api/window";
import { getCurrentWindow } from "@tauri-apps/api/window";
const window = getCurrentWindow();
@@ -79,13 +83,14 @@ const currentPageIndex = useCurrentNavigationIndex(navigation);
const quickActions: Array<QuickActionNav> = [
{
icon: UserGroupIcon,
action: async () => {
},
action: async () => {},
},
{
icon: BellIcon,
action: async () => {
},
action: async () => {},
},
];
const queue = useQueueState();
const currentQueueObject = computed(() => queue.value.queue.at(0));
</script>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { ArrowDownTrayIcon } from "@heroicons/vue/20/solid";
const props = defineProps<{ object?: QueueState["queue"][0] }>();
</script>
<template>
<NuxtLink
to="/queue"
class="transition inline-flex items-center cursor-pointer rounded-sm px-4 py-1.5 bg-zinc-900 hover:bg-zinc-800 relative"
>
<ArrowDownTrayIcon
:class="[
'h-5 z-50',
props.object
? 'text-white hover:text-zinc-300'
: 'text-zinc-600 hover:text-zinc-300',
]"
/>
<div
v-if="props.object?.progress"
class="transition-all absolute left-0 top-0 bottom-0 bg-blue-600 z-10"
:style="{ width: `${props.object.progress * 99 + 1}%` }"
/>
</NuxtLink>
</template>
+1 -1
View File
@@ -21,7 +21,7 @@
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute bg-zinc-900 right-0 top-10 z-10 w-56 origin-top-right focus:outline-none shadow-md"
class="absolute bg-zinc-900 right-0 top-10 z-50 w-56 origin-top-right focus:outline-none shadow-md"
>
<PanelWidget class="flex-col gap-y-2">
<NuxtLink
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<button
type="submit"
class="inline-flex h-9 items-center justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="inline-flex h-9 items-center justify-center rounded-md bg-blue-600 hover:bg-blue-500 disabled:text-zinc-500 disabled:bg-blue-900 disabled:hover:bg-blue-900 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<div v-if="props.loading" role="status">
<svg
+1 -1
View File
@@ -1,5 +1,5 @@
import type { RouteLocationNormalized } from "vue-router";
import type { NavigationItem } from "~/components/types";
import type { NavigationItem } from "~/types";
export const useCurrentNavigationIndex = (
navigation: Array<NavigationItem>
+31
View File
@@ -0,0 +1,31 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Game, GameStatus } from "~/types";
const gameRegistry: { [key: string]: Game } = {};
const gameStatusRegistry: { [key: string]: Ref<GameStatus> } = {};
export const useGame = async (id: string) => {
if (!gameRegistry[id]) {
const data: { game: Game; status: GameStatus } = await invoke(
"fetch_game",
{
id,
}
);
gameRegistry[id] = data.game;
if (!gameStatusRegistry[id]) {
gameStatusRegistry[id] = ref(data.status);
listen(`update_game/${id}`, (event) => {
const payload: { status: GameStatus } = event.payload as any;
gameStatusRegistry[id].value = payload.status;
});
}
}
const game = gameRegistry[id];
const status = gameStatusRegistry[id];
return { game, status };
};
+13
View File
@@ -0,0 +1,13 @@
import { listen } from "@tauri-apps/api/event";
export type QueueState = {
queue: Array<{ id: string; status: string, progress: number | null }>;
};
export const useQueueState = () =>
useState<QueueState>("queue", () => ({ queue: [] }));
listen("update_queue", (event) => {
const queue = useQueueState();
queue.value = event.payload as QueueState;
});
+4
View File
@@ -6,3 +6,7 @@
</div>
</div>
</template>
<script setup lang="ts">
const queueState = useQueueState();
</script>
+3 -4
View File
@@ -14,22 +14,21 @@
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@prisma/client": "5.20.0",
"@tauri-apps/api": ">=2.0.0",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-shell": ">=2.0.0",
"nuxt": "^3.13.0",
"scss": "^0.2.4",
"vue": "latest",
"vue-router": "latest"
"vue-router": "latest",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@tauri-apps/cli": ">=2.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"prisma": "^5.20.0",
"sass-embedded": "^1.79.4",
"tailwindcss": "^3.4.13"
},
+16 -2
View File
@@ -20,9 +20,15 @@
We were unable to contact your Drop instance. See if you can open it
in your web browser, or contact your server admin for help.
</p>
<div class="mt-10">
<div class="mt-10 space-x-10">
<button
@click="() => retry()"
class="inline-flex gap-x-2 items-center text-sm text-left font-semibold leading-7 text-white"
>
Retry <ArrowPathIcon class="w-5 h-5" />
</button>
<NuxtLink
to="/setup"
to="/setup"
class="text-sm text-left font-semibold leading-7 text-blue-600"
>
Connect to different instance <span aria-hidden="true">&rarr;</span>
@@ -68,7 +74,15 @@
</template>
<script setup lang="ts">
import { ArrowPathIcon } from "@heroicons/vue/24/outline";
import { invoke } from "@tauri-apps/api/core";
definePageMeta({
layout: "mini",
});
async function retry() {
await invoke("retry_connect");
location.reload();
}
</script>
+3 -5
View File
@@ -13,7 +13,7 @@
>
<div class="flex items-center min-w-0 gap-x-2">
<img
class="h-5 w-5 flex-none object-cover rounded-sm bg-zinc-900"
class="h-5 w-auto flex-none object-cover rounded-sm bg-zinc-900"
:src="icons[navIdx]"
alt=""
/>
@@ -40,12 +40,10 @@
</template>
<script setup lang="ts">
import type { Game } from "@prisma/client";
import { invoke } from "@tauri-apps/api/core";
import type { NavigationItem } from "~/types";
import type { Game, NavigationItem } from "~/types";
const rawGames = await invoke<string>("fetch_library");
const games: Array<Game> = JSON.parse(rawGames);
const games: Array<Game> = await invoke("fetch_library");
const icons = await Promise.all(games.map((e) => useObject(e.mIconId)));
const navigation = games.map((e) => {
+340 -9
View File
@@ -3,7 +3,7 @@
class="mx-auto w-full relative flex flex-col justify-center pt-64 z-10 overflow-hidden"
>
<!-- banner image -->
<div class="absolute flex top-0 h-fit inset-x-0 -z-[20]">
<div class="absolute flex top-0 h-fit inset-x-0 z-[-20]">
<img :src="bannerUrl" class="w-full h-auto object-cover" />
<h1
class="absolute inset-x-0 w-full text-center top-32 -translate-y-[50%] text-4xl text-zinc-100 font-bold font-display z-50"
@@ -18,24 +18,355 @@
<div class="w-full min-h-screen mx-auto bg-zinc-900 px-5 py-6">
<!-- game toolbar -->
<div>
<GameButton v-model="status" />
<GameStatusButton @install="() => installFlow()" :status="status" />
</div>
</div>
</div>
<TransitionRoot as="template" :show="installFlowOpen">
<Dialog class="relative z-50" @close="installFlowOpen = false">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity"
/>
</TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div
class="flex min-h-full items-start justify-center p-4 text-center sm:items-center sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<form
@submit.prevent="() => install()"
class="relative transform rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
>
<div class="px-4 pb-4 pt-5 space-y-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<DialogTitle
as="h3"
class="text-base font-semibold text-zinc-100"
>Install {{ game.mName }}?
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-zinc-400">
Drop will add {{ game.mName }} to the queue to be
downloaded. While downloading, Drop may use up a large
amount of resources, particularly network bandwidth and
CPU utilisation.
</p>
</div>
</div>
</div>
<div class="space-y-6">
<div v-if="versionOptions && versionOptions.length > 0">
<Listbox as="div" v-model="installVersionIndex">
<ListboxLabel
class="block text-sm/6 font-medium text-zinc-100"
>Version</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"
>{{
versionOptions[installVersionIndex].versionName
}}
on
{{
versionOptions[installVersionIndex].platform
}}</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="(version, versionIdx) in versionOptions"
:key="version.versionName"
:value="versionIdx"
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',
]"
>{{ version.versionName }} on
{{ version.platform }}</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>
</Listbox>
</div>
<div v-else 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">
There are no versions to install. Please contact your
server admin or try again later.
</h3>
</div>
</div>
</div>
<div v-if="installDirs">
<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>
</Listbox>
</div>
</div>
<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>
</div>
<div
class="rounded-b-lg bg-zinc-800 px-4 py-3 sm:flex sm:gap-x-2 sm:flex-row-reverse sm:px-6"
>
<LoadingButton
:disabled="
!(
versionOptions &&
versionOptions.length > 0 &&
!installDir
)
"
:loading="installLoading"
type="submit"
class="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="installFlowOpen = false"
ref="cancelButtonRef"
>
Cancel
</button>
</div>
</form>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import type { Game } from "@prisma/client";
import {
Dialog,
DialogTitle,
TransitionChild,
TransitionRoot,
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Game, GameStatus } from "~/types";
const route = useRoute();
const id = route.params.id;
const id = route.params.id.toString();
const raw: { game: Game; status: any } = JSON.parse(
await invoke<string>("fetch_game", { id: id })
);
const game = ref(raw.game);
const status = ref(raw.status);
const { game: rawGame, status } = await useGame(id);
const game = ref(rawGame);
const bannerUrl = await useObject(game.value.mBannerId);
const installFlowOpen = ref(false);
const versionOptions = ref<
undefined | Array<{ versionName: string; platform: string }>
>();
const installDirs = ref<undefined | Array<string>>();
async function installFlow() {
installFlowOpen.value = true;
try {
versionOptions.value = await invoke("fetch_game_verion_options", {
gameId: game.value.id,
});
installDirs.value = await invoke("fetch_download_dir_stats");
} catch (error) {
installError.value = (error as string).toString();
}
}
const installLoading = ref(false);
const installError = ref<string | undefined>();
const installVersionIndex = ref(0);
const installDir = ref(0);
async function install() {
try {
if (!versionOptions.value)
throw new Error("Versions have not been loaded.");
installLoading.value = true;
await invoke("download_game", {
gameId: game.value.id,
gameVersion: versionOptions.value[installVersionIndex.value].versionName,
installDir: installDir.value,
});
installLoading.value = false;
installFlowOpen.value = false;
} catch (error) {
installError.value = (error as string).toString();
}
}
</script>
+24
View File
@@ -0,0 +1,24 @@
<template>
<draggable v-model="queue.queue" @end="onEnd">
<template #item="{ element }: { element: (typeof queue.value.queue)[0] }">
<div class="text-white">
{{ element.id }}
</div>
</template>
</draggable>
{{ current }}
{{ rest }}
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
const queue = useQueueState();
const current = computed(() => queue.value.queue.at(0));
const rest = computed(() => queue.value.queue.slice(1));
async function onEnd(event: { oldIndex: number; newIndex: number }) {
await invoke("move_game_in_queue", { oldIndex: event.oldIndex, newIndex: event.newIndex });
}
</script>
+4 -4
View File
@@ -13,8 +13,8 @@
:href="item.route"
:class="[
itemIdx === currentPageIndex
? 'bg-zinc-800/50 text-blue-600'
: 'text-zinc-400 hover:bg-zinc-800/30 hover:text-blue-600',
? 'bg-zinc-800/50 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/30 hover:text-zinc-200',
'transition group flex gap-x-3 rounded-md p-2 pr-12 text-sm font-semibold leading-6',
]"
>
@@ -22,8 +22,8 @@
:is="item.icon"
:class="[
itemIdx === currentPageIndex
? 'text-blue-600'
: 'text-zinc-400 group-hover:text-blue-600',
? 'text-zinc-100'
: 'text-zinc-400 group-hover:text-zinc-200',
'transition h-6 w-6 shrink-0',
]"
aria-hidden="true"
+237 -2
View File
@@ -1,3 +1,238 @@
<template>
</template>
<div>
<div class="border-b border-zinc-600 py-2 px-1">
<div
class="-ml-4 -mt-2 flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<div class="ml-4 mt-2">
<h3 class="text-base font-display font-semibold text-zinc-100">
Install directories
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-xl">
This is where Drop will download game files to, and store them
indefinitely while you play. Drop and games may store other
information elsewhere, like saves or mods.
</p>
</div>
<div class="ml-4 mt-2 shrink-0">
<button
@click="() => (open = true)"
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Add new directory
</button>
</div>
</div>
</div>
<ul role="list" class="divide-y divide-gray-800">
<li
v-for="(dir, dirIdx) in dirs"
:key="dir"
class="flex justify-between gap-x-6 py-5"
>
<div class="flex min-w-0 gap-x-4">
<FolderIcon
class="h-6 w-6 text-blue-600 flex-none rounded-full"
alt=""
/>
<div class="min-w-0 flex-auto">
<p class="text-sm/6 text-zinc-100">
{{ dir }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-x-6">
<button
@click="() => deleteDirectory(dirIdx)"
:disabled="dirs.length <= 1"
:class="[
dirs.length <= 1
? 'text-zinc-700'
: 'text-zinc-400 hover:text-zinc-100',
'-m-2.5 block p-2.5',
]"
>
<span class="sr-only">Open options</span>
<TrashIcon class="size-5" aria-hidden="true" />
</button>
</div>
</li>
</ul>
</div>
<TransitionRoot as="template" :show="open">
<Dialog class="relative z-50" @close="open = false">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity"
/>
</TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div
class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel
class="relative transform overflow-hidden rounded-lg bg-zinc-900 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
>
<div class="sm:flex sm:items-start">
<div class="mt-3 w-full sm:ml-4 sm:mt-0">
<div>
<label
for="dir"
class="block text-sm/6 font-medium text-zinc-100"
>Select game directory</label
>
<div class="mt-2">
<button
@click="() => selectDirectory()"
class="block text-left w-full rounded-md border-0 px-3 py-1.5 text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 bg-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm/6"
>
{{
currentDirectory ?? "Click to select a directory..."
}}
</button>
</div>
<p class="mt-2 text-sm text-zinc-400" id="dir-description">
Select an empty directory to add.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<LoadingButton
:disabled="currentDirectory == undefined"
type="button"
:loading="createDirectoryLoading"
@click="() => submitDirectory()"
:class="[
'inline-flex w-full shadow-sm sm:ml-3 sm:w-auto',
currentDirectory === undefined
? 'text-zinc-400 bg-blue-600/10 hover:bg-blue-600/10'
: 'text-white bg-blue-600 hover:bg-blue-500',
]"
>
Upload
</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-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => cancelDirectory()"
ref="cancelButtonRef"
>
Cancel
</button>
</div>
<div v-if="error" class="mt-3 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">
{{ error }}
</h3>
</div>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { FolderIcon, TrashIcon, XCircleIcon } from "@heroicons/vue/16/solid";
import { invoke } from "@tauri-apps/api/core";
const open = ref(false);
const currentDirectory = ref<string | undefined>(undefined);
const error = ref<string | undefined>(undefined);
const createDirectoryLoading = ref(false);
const dirs = ref<Array<string>>([]);
async function updateDirs() {
const newDirs = await invoke<Array<string>>("fetch_download_dir_stats");
dirs.value = newDirs;
}
await updateDirs();
async function selectDirectoryDialog(): Promise<string> {
const res = await invoke("plugin:dialog|open", {
options: { directory: true },
});
return res as string;
}
async function selectDirectory() {
try {
const dir = await selectDirectoryDialog();
currentDirectory.value = dir;
} catch (e) {
error.value = e as string;
}
}
function cancelDirectory() {
open.value = false;
currentDirectory.value = undefined;
}
async function submitDirectory() {
try {
error.value = undefined;
if (!currentDirectory.value)
throw new Error("Please select a directory first.");
createDirectoryLoading.value = true;
// Add directory
await invoke("add_download_dir", { newDir: currentDirectory.value });
// Update list
await updateDirs();
currentDirectory.value = undefined;
createDirectoryLoading.value = false;
open.value = false;
} catch (e) {
error.value = e as string;
createDirectoryLoading.value = false;
}
}
async function deleteDirectory(index: number) {
await invoke("delete_download_dir", { index });
await updateDirs();
}
</script>
+32 -7
View File
@@ -7,8 +7,29 @@
@click="startGameDownload"
>
Download game
<span v-if="progress != 0"> ({{ Math.floor(progress * 1000) / 10 }}%) </span>
<span v-if="progress != 0">
({{ Math.floor(progress * 1000) / 10 }}%)
</span>
</button>
<button
class="w-full rounded-md p-4 bg-blue-600 text-white"
@click="stopGameDownload"
>
Cancel game download
</button>
<button
class="w-full rounded-md p-4 bg-blue-600 text-white"
@click="pause"
>
Pause game download
</button>
<button
class="w-full rounded-md p-4 bg-blue-600 text-white"
@click="resume"
>
Resume game download
</button>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
@@ -18,15 +39,10 @@ const versionName = ref("");
const progress = ref(0);
async function startGameDownload() {
await invoke("download_game", {
gameId: gameId.value,
gameVersion: versionName.value,
});
setInterval(() => {
(async () => {
const currentProgress = await invoke<number>(
"get_game_download_progress",
"get_current_game_download_progress",
{
gameId: gameId.value,
}
@@ -36,4 +52,13 @@ async function startGameDownload() {
})();
}, 100);
}
async function stopGameDownload() {
await invoke("cancel_game_download", { gameId: gameId.value });
}
async function pause() {
await invoke("pause_game_downloads");
}
async function resume() {
await invoke("resume_game_downloads");
}
</script>
+5
View File
@@ -0,0 +1,5 @@
import draggable from "vuedraggable";
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component("draggable", draggable);
});
-150
View File
@@ -1,150 +0,0 @@
// This should be copied from the main Drop repo
// TODO: do this automatically
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
username String @unique
admin Boolean @default(false)
email String
displayName String
profilePicture String // Object
authMecs LinkedAuthMec[]
clients Client[]
}
enum AuthMec {
Simple
}
model LinkedAuthMec {
userId String
mec AuthMec
credentials Json
user User @relation(fields: [userId], references: [id])
@@id([userId, mec])
}
enum ClientCapabilities {
DownloadAggregation
}
enum Platform {
Windows @map("windows")
Linux @map("linux")
}
// References a device
model Client {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
endpoint String
capabilities ClientCapabilities[]
name String
platform Platform
lastConnected DateTime
}
enum MetadataSource {
Custom
GiantBomb
}
model Game {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
// Any field prefixed with m is filled in from metadata
// Acts as a cache so we can search and filter it
mName String // Name of game
mShortDescription String // Short description
mDescription String // Supports markdown
mDevelopers Developer[]
mPublishers Publisher[]
mReviewCount Int
mReviewRating Float
mIconId String // linked to objects in s3
mBannerId String // linked to objects in s3
mCoverId String
mImageLibrary String[] // linked to objects in s3
versions GameVersion[]
libraryBasePath String @unique // Base dir for all the game versions
@@unique([metadataSource, metadataId], name: "metadataKey")
}
// A particular set of files that relate to the version
model GameVersion {
gameId String
game Game @relation(fields: [gameId], references: [id])
versionName String // Sub directory for the game files
platform Platform
launchCommand String // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
setupCommand String // Command to setup game (dependencies and such)
dropletManifest Json // Results from droplet
versionIndex Int
delta Boolean @default(false)
@@id([gameId, versionName])
}
model Developer {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
metadataOriginalQuery String
mName String
mShortDescription String
mDescription String
mLogo String
mBanner String
mWebsite String
games Game[]
@@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey")
}
model Publisher {
id String @id @default(uuid())
metadataSource MetadataSource
metadataId String
metadataOriginalQuery String
mName String
mShortDescription String
mDescription String
mLogo String
mBanner String
mWebsite String
games Game[]
@@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey")
}
+874 -418
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -68,7 +68,7 @@ features = ["vendored"]
[dependencies.rustbreak]
version = "2"
features = ["bin_enc"] # You can also use "yaml_enc" or "bin_enc"
features = [] # You can also use "yaml_enc" or "bin_enc"
[dependencies.reqwest]
version = "0.12"
+14 -6
View File
@@ -90,9 +90,7 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc
let path_chunks: Vec<&str> = path.split("/").collect();
if path_chunks.len() != 3 {
app.emit("auth/failed", ()).unwrap();
return Err(RemoteAccessError::GenericErrror(
"Invalid number of handshake chunks".to_string(),
));
return Err(RemoteAccessError::InvalidResponse);
}
let base_url = {
@@ -163,9 +161,7 @@ async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
let response = client.post(endpoint.to_string()).json(&body).send().await?;
if response.status() != 200 {
return Err("Failed to create redirect URL. Please try again later."
.to_string()
.into());
return Err(RemoteAccessError::InvalidRedirect);
}
let redir_url = response.text().await?;
@@ -187,6 +183,18 @@ pub async fn auth_initiate<'a>() -> Result<(), String> {
Ok(())
}
#[tauri::command]
pub fn retry_connect(state: tauri::State<'_, Mutex<AppState>>) -> Result<(), ()> {
let (app_status, user) = setup()?;
let mut guard = state.lock().unwrap();
guard.status = app_status;
guard.user = user;
drop(guard);
Ok(())
}
pub fn setup() -> Result<(AppStatus, Option<User>), ()> {
let data = DB.borrow_data().unwrap();
+87 -27
View File
@@ -6,8 +6,9 @@ use std::{
};
use directories::BaseDirs;
use rustbreak::{deser::Bincode, PathDatabase};
use serde::{Deserialize, Serialize};
use log::debug;
use rustbreak::{DeSerError, DeSerializer, PathDatabase};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use url::Url;
use crate::DB;
@@ -20,21 +21,36 @@ pub struct DatabaseAuth {
pub client_id: String,
}
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum DatabaseGameStatus {
Remote,
Downloading,
Installed,
Updating,
Remote {},
Queued { version_name: String },
Downloading { version_name: String },
SetupRequired { version_name: String },
Installed { version_name: String },
Updating { version_name: String },
Uninstalling {},
}
Uninstalling,
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GameVersion {
pub version_index: usize,
pub version_name: String,
pub launch_command: String,
pub setup_command: String,
pub platform: String,
}
#[derive(Serialize, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseGames {
pub install_dirs: Vec<String>,
// Guaranteed to exist if the game also exists in the app state map
pub games_statuses: HashMap<String, DatabaseGameStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
}
#[derive(Serialize, Clone, Deserialize)]
@@ -47,8 +63,22 @@ pub struct Database {
pub static DATA_ROOT_DIR: LazyLock<Mutex<PathBuf>> =
LazyLock::new(|| Mutex::new(BaseDirs::new().unwrap().data_dir().join("drop")));
// Custom JSON serializer to support everything we need
#[derive(Debug, Default, Clone)]
pub struct DropDatabaseSerializer;
impl<T: Serialize + DeserializeOwned> DeSerializer<T> for DropDatabaseSerializer {
fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> {
serde_json::to_vec(val).map_err(|e| DeSerError::Internal(e.to_string()))
}
fn deserialize<R: std::io::Read>(&self, s: R) -> rustbreak::error::DeSerResult<T> {
serde_json::from_reader(s).map_err(|e| DeSerError::Internal(e.to_string()))
}
}
pub type DatabaseInterface =
rustbreak::Database<Database, rustbreak::backend::PathBackend, Bincode>;
rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer>;
pub trait DatabaseImpls {
fn set_up_database() -> DatabaseInterface;
@@ -61,26 +91,31 @@ impl DatabaseImpls for DatabaseInterface {
let db_path = data_root_dir.join("drop.db");
let games_base_dir = data_root_dir.join("games");
let default = Database {
auth: None,
base_url: "".to_string(),
games: DatabaseGames {
install_dirs: vec![games_base_dir.to_str().unwrap().to_string()],
games_statuses: HashMap::new(),
},
};
debug!("Creating data directory at {:?}", data_root_dir);
create_dir_all(data_root_dir.clone()).unwrap();
debug!("Creating games directory");
create_dir_all(games_base_dir.clone()).unwrap();
#[allow(clippy::let_and_return)]
let db = match fs::exists(db_path.clone()).unwrap() {
let exists = fs::exists(db_path.clone()).unwrap();
match exists {
true => PathDatabase::load_from_path(db_path).expect("Database loading failed"),
false => {
create_dir_all(data_root_dir.clone()).unwrap();
create_dir_all(games_base_dir.clone()).unwrap();
PathDatabase::create_at_path(db_path, default).unwrap()
let default = Database {
auth: None,
base_url: "".to_string(),
games: DatabaseGames {
install_dirs: vec![games_base_dir.to_str().unwrap().to_string()],
games_statuses: HashMap::new(),
game_versions: HashMap::new(),
},
};
debug!("Creating database at path {}", db_path.as_os_str().to_str().unwrap());
PathDatabase::create_at_path(db_path, default)
.expect("Database could not be created")
}
};
db
}
}
fn database_is_set_up(&self) -> bool {
@@ -94,7 +129,7 @@ impl DatabaseImpls for DatabaseInterface {
}
#[tauri::command]
pub fn add_new_download_dir(new_dir: String) -> Result<(), String> {
pub fn add_download_dir(new_dir: String) -> Result<(), String> {
// Check the new directory is all good
let new_dir_path = Path::new(&new_dir);
if new_dir_path.exists() {
@@ -107,8 +142,8 @@ pub fn add_new_download_dir(new_dir: String) -> Result<(), String> {
let dir_contents = new_dir_path
.read_dir()
.map_err(|e| format!("Unable to check directory contents: {}", e))?;
if dir_contents.count() == 0 {
return Err("Path is not empty".to_string());
if dir_contents.count() != 0 {
return Err("Directory is not empty".to_string());
}
} else {
create_dir_all(new_dir_path)
@@ -117,8 +152,33 @@ pub fn add_new_download_dir(new_dir: String) -> Result<(), String> {
// Add it to the dictionary
let mut lock = DB.borrow_data_mut().unwrap();
if lock.games.install_dirs.contains(&new_dir) {
return Err("Download directory already used".to_string());
}
lock.games.install_dirs.push(new_dir);
drop(lock);
DB.save().unwrap();
Ok(())
}
#[tauri::command]
pub fn delete_download_dir(index: usize) -> Result<(), String> {
let mut lock = DB.borrow_data_mut().unwrap();
lock.games.install_dirs.remove(index);
drop(lock);
DB.save().unwrap();
Ok(())
}
// Will, in future, return disk/remaining size
// Just returns the directories that have been set up
#[tauri::command]
pub fn fetch_download_dir_stats() -> Result<Vec<String>, String> {
let lock = DB.borrow_data().unwrap();
let directories = lock.games.install_dirs.clone();
drop(lock);
Ok(directories)
}
+127 -57
View File
@@ -1,20 +1,24 @@
use crate::auth::generate_authorization_header;
use crate::db::DatabaseImpls;
use crate::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::downloads::progress_object::ProgressHandle;
use crate::remote::RemoteAccessError;
use crate::DB;
use log::info;
use log::{debug, error, info};
use rayon::ThreadPoolBuilder;
use std::fmt::{Display, Formatter};
use std::fs::{create_dir_all, File};
use std::io;
use std::path::Path;
use std::sync::Mutex;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use urlencoding::encode;
#[cfg(target_os = "linux")]
use rustix::fs::{fallocate, FallocateFlags};
use super::download_logic::download_game_chunk;
use super::download_manager::DownloadManagerSignal;
use super::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use super::progress_object::ProgressObject;
@@ -25,51 +29,66 @@ pub struct GameDownloadAgent {
pub target_download_dir: usize,
contexts: Mutex<Vec<DropDownloadContext>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: ProgressObject,
pub progress: Arc<ProgressObject>,
sender: Sender<DownloadManagerSignal>,
}
#[derive(Debug)]
pub enum GameDownloadError {
CommunicationError(RemoteAccessError),
ChecksumError,
SetupError(String),
LockError,
Communication(RemoteAccessError),
Checksum,
Setup(SetupError),
Lock,
IoError(io::Error),
DownloadError,
}
#[derive(Debug)]
pub enum SetupError {
Context,
}
impl Display for GameDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
GameDownloadError::CommunicationError(error) => write!(f, "{}", error),
GameDownloadError::SetupError(error) => write!(f, "{}", error),
GameDownloadError::LockError => write!(f, "Failed to acquire lock. Something has gone very wrong internally. Please restart the application"),
GameDownloadError::ChecksumError => write!(f, "Checksum failed to validate for download"),
GameDownloadError::Communication(error) => write!(f, "{}", error),
GameDownloadError::Setup(error) => write!(f, "{:?}", error),
GameDownloadError::Lock => write!(f, "Failed to acquire lock. Something has gone very wrong internally. Please restart the application"),
GameDownloadError::Checksum => write!(f, "Checksum failed to validate for download"),
GameDownloadError::IoError(error) => write!(f, "{}", error),
GameDownloadError::DownloadError => write!(f, "Download failed. See Download Manager status for specific error"),
}
}
}
impl GameDownloadAgent {
pub fn new(id: String, version: String, target_download_dir: usize) -> Self {
pub fn new(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Self {
// Don't run by default
let status = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
Self {
id,
version,
control_flag: status.clone(),
control_flag,
manifest: Mutex::new(None),
target_download_dir,
contexts: Mutex::new(Vec::new()),
progress: ProgressObject::new(0, 0),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
}
}
// Blocking
// Requires mutable self
pub fn setup_download(&mut self) -> Result<(), GameDownloadError> {
pub fn setup_download(&self) -> Result<(), GameDownloadError> {
self.ensure_manifest_exists()?;
info!("Ensured manifest exists");
self.generate_contexts()?;
info!("Generated contexts");
self.ensure_contexts()?;
info!("Ensured contexts exists");
self.control_flag.set(DownloadThreadControlFlag::Go);
@@ -77,23 +96,23 @@ impl GameDownloadAgent {
}
// Blocking
pub fn download(&mut self) -> Result<(), GameDownloadError> {
pub fn download(&self) -> Result<(), GameDownloadError> {
self.setup_download()?;
self.run();
self.set_progress_object_params();
self.run().map_err(|_| GameDownloadError::DownloadError)?;
Ok(())
}
pub fn ensure_manifest_exists(&mut self) -> Result<(), GameDownloadError> {
pub fn ensure_manifest_exists(&self) -> Result<(), GameDownloadError> {
if self.manifest.lock().unwrap().is_some() {
return Ok(());
}
// Explicitly propagate error
self.download_manifest()
}
fn download_manifest(&mut self) -> Result<(), GameDownloadError> {
fn download_manifest(&self) -> Result<(), GameDownloadError> {
let base_url = DB.fetch_base_url();
let manifest_url = base_url
.join(
@@ -115,35 +134,52 @@ impl GameDownloadAgent {
.unwrap();
if response.status() != 200 {
return Err(GameDownloadError::CommunicationError(
format!(
"Failed to download game manifest: {} {}",
return Err(GameDownloadError::Communication(
RemoteAccessError::ManifestDownloadFailed(
response.status(),
response.text().unwrap()
)
.into(),
response.text().unwrap(),
),
));
}
let manifest_download = response.json::<DropManifest>().unwrap();
let length = manifest_download
.values()
.map(|chunk| {
return chunk.lengths.iter().sum::<usize>();
})
.sum::<usize>();
let chunk_count = manifest_download
.values()
.map(|chunk| chunk.lengths.len())
.sum();
self.progress = ProgressObject::new(length, chunk_count);
if let Ok(mut manifest) = self.manifest.lock() {
*manifest = Some(manifest_download);
return Ok(());
}
Err(GameDownloadError::LockError)
Err(GameDownloadError::Lock)
}
fn set_progress_object_params(&self) {
// Avoid re-setting it
if self.progress.get_max() != 0 {
return;
}
let lock = self.contexts.lock().unwrap();
let length = lock.len();
let chunk_count = lock.iter().map(|chunk| chunk.length).sum();
debug!("Setting ProgressObject max to {}", chunk_count);
self.progress.set_max(chunk_count);
debug!("Setting ProgressObject size to {}", length);
self.progress.set_size(length);
debug!("Setting ProgressObject time to now");
self.progress.set_time_now();
}
pub fn ensure_contexts(&self) -> Result<(), GameDownloadError> {
let context_lock = self.contexts.lock().unwrap();
if !context_lock.is_empty() {
return Ok(());
}
drop(context_lock);
self.generate_contexts()?;
Ok(())
}
pub fn generate_contexts(&self) -> Result<(), GameDownloadError> {
@@ -152,7 +188,6 @@ impl GameDownloadAgent {
drop(db_lock);
let manifest = self.manifest.lock().unwrap().clone().unwrap();
let version = self.version.clone();
let game_id = self.id.clone();
let data_base_dir_path = Path::new(&data_base_dir);
@@ -173,19 +208,20 @@ impl GameDownloadAgent {
for (index, length) in chunk.lengths.iter().enumerate() {
contexts.push(DropDownloadContext {
file_name: raw_path.to_string(),
version: version.to_string(),
version: chunk.version_name.to_string(),
offset: running_offset,
index,
game_id: game_id.to_string(),
path: path.clone(),
checksum: chunk.checksums[index].clone(),
length: *length,
});
running_offset += *length as u64;
}
#[cfg(target_os = "linux")]
if running_offset > 0 {
fallocate(file, FallocateFlags::empty(), 0, running_offset).unwrap();
let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset);
}
}
@@ -194,35 +230,69 @@ impl GameDownloadAgent {
return Ok(());
}
Err(GameDownloadError::SetupError(
String::from("Failed to generate download contexts"),
))
Err(GameDownloadError::Setup(SetupError::Context))
}
pub fn run(&self) {
const DOWNLOAD_MAX_THREADS: usize = 4;
pub fn run(&self) -> Result<(), ()> {
info!("downloading game: {}", self.id);
const DOWNLOAD_MAX_THREADS: usize = 1;
let pool = ThreadPoolBuilder::new()
.num_threads(DOWNLOAD_MAX_THREADS)
.build()
.unwrap();
let completed_indexes = Arc::new(Mutex::new(Vec::new()));
let completed_indexes_loop_arc = completed_indexes.clone();
pool.scope(move |scope| {
let contexts = self.contexts.lock().unwrap();
for (index, context) in contexts.iter().enumerate() {
let context = context.clone();
let control_flag = self.control_flag.clone(); // Clone arcs
let progress = self.progress.get(index);
let progress = self.progress.get(index); // Clone arcs
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
let completed_indexes_ref = completed_indexes_loop_arc.clone();
scope.spawn(move |_| {
info!(
"starting download for file {} {}",
context.file_name, context.index
);
download_game_chunk(context, control_flag, progress).unwrap();
match download_game_chunk(context.clone(), control_flag, progress_handle) {
Ok(res) => {
if res {
let mut lock = completed_indexes_ref.lock().unwrap();
lock.push(index);
}
}
Err(e) => {
error!("GameDownloadError: {}", e);
self.sender.send(DownloadManagerSignal::Error(e)).unwrap();
}
}
});
}
})
});
let mut context_lock = self.contexts.lock().unwrap();
let mut completed_lock = completed_indexes.lock().unwrap();
// Sort desc so we don't have to modify indexes
completed_lock.sort_by(|a, b| b.cmp(a));
for index in completed_lock.iter() {
context_lock.remove(*index);
}
// If we're not out of contexts, we're not done, so we don't fire completed
if !context_lock.is_empty() {
info!("da for {} exited without completing", self.id.clone());
return Ok(());
}
// We've completed
self.sender
.send(DownloadManagerSignal::Completed(self.id.clone()))
.unwrap();
Ok(())
}
}
@@ -1,53 +1,57 @@
use std::sync::{Arc, Mutex};
use std::sync::Mutex;
use log::info;
use rayon::spawn;
use crate::{downloads::download_agent::GameDownloadAgent, AppState};
use crate::AppState;
#[tauri::command]
pub fn download_game(
game_id: String,
game_version: String,
install_dir: usize,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), String> {
info!("beginning game download...");
let mut download_agent = GameDownloadAgent::new(game_id.clone(), game_version.clone(), 0);
// Setup download requires mutable
download_agent.setup_download().unwrap();
let mut lock: std::sync::MutexGuard<'_, AppState> = state.lock().unwrap();
let download_agent_ref = Arc::new(download_agent);
lock.game_downloads
.insert(game_id, download_agent_ref.clone());
// Run it in another thread
spawn(move || {
// Run doesn't require mutable
download_agent_ref.clone().run();
});
Ok(())
state
.lock()
.unwrap()
.download_manager
.queue_game(game_id, game_version, install_dir)
.map_err(|_| "An error occurred while communicating with the download manager.".to_string())
}
#[tauri::command]
pub fn get_game_download_progress(
state: tauri::State<'_, Mutex<AppState>>,
game_id: String,
) -> Result<f64, String> {
let download_agent = use_download_agent(state, game_id)?;
let progress = &download_agent.progress;
Ok(progress.get_progress())
pub fn pause_game_downloads(state: tauri::State<'_, Mutex<AppState>>) {
state.lock().unwrap().download_manager.pause_downloads()
}
#[tauri::command]
pub fn resume_game_downloads(state: tauri::State<'_, Mutex<AppState>>) {
state.lock().unwrap().download_manager.resume_downloads()
}
#[tauri::command]
pub fn move_game_in_queue(
state: tauri::State<'_, Mutex<AppState>>,
old_index: usize,
new_index: usize,
) {
state
.lock()
.unwrap()
.download_manager
.rearrange(old_index, new_index)
}
/*
#[tauri::command]
pub fn get_current_write_speed(state: tauri::State<'_, Mutex<AppState>>) {}
*/
/*
fn use_download_agent(
state: tauri::State<'_, Mutex<AppState>>,
game_id: String,
) -> Result<Arc<GameDownloadAgent>, String> {
let lock = state.lock().unwrap();
let download_agent = lock.game_downloads.get(&game_id).ok_or("Invalid game ID")?;
let download_agent = lock.download_manager.get(&game_id).ok_or("Invalid game ID")?;
Ok(download_agent.clone()) // Clones the Arc, not the underlying data structure
}
*/
@@ -3,21 +3,21 @@ use crate::db::DatabaseImpls;
use crate::downloads::manifest::DropDownloadContext;
use crate::remote::RemoteAccessError;
use crate::DB;
use log::warn;
use md5::{Context, Digest};
use reqwest::blocking::Response;
use std::io::Read;
use std::sync::atomic::AtomicUsize;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, ErrorKind, Seek, SeekFrom, Write},
io::{self, BufWriter, Seek, SeekFrom, Write},
path::PathBuf,
sync::Arc,
};
use urlencoding::encode;
use super::download_agent::GameDownloadError;
use super::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use super::progress_object::ProgressHandle;
pub struct DropWriter<W: Write> {
hasher: Context,
@@ -39,17 +39,19 @@ impl DropWriter<File> {
// Write automatically pushes to file and hasher
impl Write for DropWriter<File> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
/*
self.hasher.write_all(buf).map_err(|e| {
io::Error::new(
ErrorKind::Other,
format!("Unable to write to hasher: {}", e),
)
})?;
*/
self.destination.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.hasher.flush()?;
// self.hasher.flush()?;
self.destination.flush()
}
}
@@ -64,7 +66,7 @@ pub struct DropDownloadPipeline<R: Read, W: Write> {
pub source: R,
pub destination: DropWriter<W>,
pub control_flag: DownloadThreadControl,
pub progress: Arc<AtomicUsize>,
pub progress: ProgressHandle,
pub size: usize,
}
impl DropDownloadPipeline<Response, File> {
@@ -72,7 +74,7 @@ impl DropDownloadPipeline<Response, File> {
source: Response,
destination: DropWriter<File>,
control_flag: DownloadThreadControl,
progress: Arc<AtomicUsize>,
progress: ProgressHandle,
size: usize,
) -> Self {
Self {
@@ -99,8 +101,7 @@ impl DropDownloadPipeline<Response, File> {
current_size += bytes_read;
buf_writer.write_all(&copy_buf[0..bytes_read])?;
self.progress
.fetch_add(bytes_read, std::sync::atomic::Ordering::Relaxed);
self.progress.add(bytes_read);
if current_size == self.size {
break;
@@ -119,10 +120,11 @@ impl DropDownloadPipeline<Response, File> {
pub fn download_game_chunk(
ctx: DropDownloadContext,
control_flag: DownloadThreadControl,
progress: Arc<AtomicUsize>,
progress: ProgressHandle,
) -> Result<bool, GameDownloadError> {
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
@@ -146,7 +148,14 @@ pub fn download_game_chunk(
.get(chunk_url)
.header("Authorization", header)
.send()
.map_err(|e| GameDownloadError::CommunicationError(RemoteAccessError::FetchError(e)))?;
.map_err(|e| GameDownloadError::Communication(e.into()))?;
if response.status() != 200 {
warn!("{}", response.text().unwrap());
return Err(GameDownloadError::Communication(
RemoteAccessError::InvalidCodeError(400),
));
}
let mut destination = DropWriter::new(ctx.path);
@@ -158,10 +167,8 @@ pub fn download_game_chunk(
let content_length = response.content_length();
if content_length.is_none() {
return Err(GameDownloadError::CommunicationError(
RemoteAccessError::GenericErrror(
"Invalid download endpoint, missing Content-Length header.".to_owned(),
),
return Err(GameDownloadError::Communication(
RemoteAccessError::InvalidResponse,
));
}
@@ -173,17 +180,21 @@ pub fn download_game_chunk(
content_length.unwrap().try_into().unwrap(),
);
let completed = pipeline.copy().unwrap();
let completed = pipeline.copy().map_err(GameDownloadError::IoError)?;
if !completed {
return Ok(false);
};
let checksum = pipeline.finish().unwrap();
/*
let checksum = pipeline
.finish()
.map_err(|e| GameDownloadError::IoError(e))?;
let res = hex::encode(checksum.0);
if res != ctx.checksum {
return Err(GameDownloadError::ChecksumError);
return Err(GameDownloadError::Checksum);
}
*/
Ok(true)
}
@@ -0,0 +1,191 @@
use std::{
any::Any,
collections::VecDeque,
fmt::Debug,
sync::{
mpsc::{SendError, Sender},
Arc, Mutex, MutexGuard,
},
thread::JoinHandle,
};
use log::info;
use serde::Serialize;
use super::{
download_agent::{GameDownloadAgent, GameDownloadError},
download_manager_builder::CurrentProgressObject,
progress_object::ProgressObject,
queue::Queue,
};
pub enum DownloadManagerSignal {
/// Resumes (or starts) the DownloadManager
Go,
/// Pauses the DownloadManager
Stop,
/// Called when a GameDownloadAgent has fully completed a download.
Completed(String),
/// Generates and appends a GameDownloadAgent
/// to the registry and queue
Queue(String, String, usize),
/// Tells the Manager to stop the current
/// download and return
Finish,
Cancel,
/// Any error which occurs in the agent
Error(GameDownloadError),
/// Pushes UI update
Update,
}
pub enum DownloadManagerStatus {
Downloading,
Paused,
Empty,
Error(GameDownloadError),
}
#[derive(Serialize, Clone)]
pub enum GameDownloadStatus {
Queued,
Downloading,
Paused,
Error,
}
/// Accessible front-end for the DownloadManager
///
/// The system works entirely through signals, both internally and externally,
/// all of which are accessible through the DownloadManagerSignal type, but
/// should not be used directly. Rather, signals are abstracted through this
/// interface.
///
/// The actual download queue may be accessed through the .edit() function,
/// which provides raw access to the underlying queue.
/// THIS EDITING IS BLOCKING!!!
pub struct DownloadManager {
terminator: JoinHandle<Result<(), ()>>,
download_queue: Queue,
progress: CurrentProgressObject,
command_sender: Sender<DownloadManagerSignal>,
}
pub struct GameDownloadAgentQueueStandin {
pub id: String,
pub status: Mutex<GameDownloadStatus>,
pub progress: Arc<ProgressObject>,
}
impl From<Arc<GameDownloadAgent>> for GameDownloadAgentQueueStandin {
fn from(value: Arc<GameDownloadAgent>) -> Self {
Self {
id: value.id.clone(),
status: Mutex::from(GameDownloadStatus::Queued),
progress: value.progress.clone(),
}
}
}
impl Debug for GameDownloadAgentQueueStandin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GameDownloadAgentQueueStandin")
.field("id", &self.id)
.finish()
}
}
#[allow(dead_code)]
impl DownloadManager {
pub fn new(
terminator: JoinHandle<Result<(), ()>>,
download_queue: Queue,
progress: CurrentProgressObject,
command_sender: Sender<DownloadManagerSignal>,
) -> Self {
Self {
terminator,
download_queue,
progress,
command_sender,
}
}
pub fn queue_game(
&self,
id: String,
version: String,
target_download_dir: usize,
) -> Result<(), SendError<DownloadManagerSignal>> {
info!("Adding game id {}", id);
self.command_sender.send(DownloadManagerSignal::Queue(
id,
version,
target_download_dir,
))?;
self.command_sender.send(DownloadManagerSignal::Go)
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>> {
self.download_queue.edit()
}
pub fn read_queue(&self) -> VecDeque<Arc<GameDownloadAgentQueueStandin>> {
self.download_queue.read()
}
pub fn get_current_game_download_progress(&self) -> Option<f64> {
let progress_object = (*self.progress.lock().unwrap()).clone()?;
Some(progress_object.get_progress())
}
pub fn rearrange_string(&self, id: String, new_index: usize) {
let mut queue = self.edit();
let current_index = get_index_from_id(&mut queue, id).unwrap();
let to_move = queue.remove(current_index).unwrap();
queue.insert(new_index, to_move);
self.command_sender
.send(DownloadManagerSignal::Update)
.unwrap();
}
pub fn rearrange(&self, current_index: usize, new_index: usize) {
let needs_pause = current_index == 0 || new_index == 0;
if needs_pause {
self.command_sender
.send(DownloadManagerSignal::Cancel)
.unwrap();
}
info!("moving {} to {}", current_index, new_index);
let mut queue = self.edit();
let to_move = queue.remove(current_index).unwrap();
queue.insert(new_index, to_move);
info!("new queue: {:?}", queue);
if needs_pause {
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
}
self.command_sender
.send(DownloadManagerSignal::Update)
.unwrap();
}
pub fn pause_downloads(&self) {
self.command_sender
.send(DownloadManagerSignal::Stop)
.unwrap();
}
pub fn resume_downloads(&self) {
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
}
pub fn ensure_terminated(self) -> Result<Result<(), ()>, Box<dyn Any + Send>> {
self.command_sender
.send(DownloadManagerSignal::Finish)
.unwrap();
self.terminator.join()
}
}
/// Takes in the locked value from .edit() and attempts to
/// get the index of whatever game_id is passed in
fn get_index_from_id(
queue: &mut MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>>,
id: String,
) -> Option<usize> {
queue
.iter()
.position(|download_agent| download_agent.id == id)
}
@@ -0,0 +1,347 @@
use std::{
collections::HashMap,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex,
},
thread::{spawn, JoinHandle},
};
use log::{error, info};
use tauri::{AppHandle, Emitter};
use crate::{
db::DatabaseGameStatus,
library::{on_game_complete, GameUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData},
DB,
};
use super::{
download_agent::{GameDownloadAgent, GameDownloadError},
download_manager::{
DownloadManager, DownloadManagerSignal, DownloadManagerStatus,
GameDownloadAgentQueueStandin, GameDownloadStatus,
},
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::ProgressObject,
queue::Queue,
};
/*
Welcome to the download manager, the most overengineered, glorious piece of bullshit.
The download manager takes a queue of game_ids and their associated
GameDownloadAgents, and then, one-by-one, executes them. It provides an interface
to interact with the currently downloading agent, and manage the queue.
When the DownloadManager is initialised, it is designed to provide a reference
which can be used to provide some instructions (the DownloadManagerInterface),
but other than that, it runs without any sort of interruptions.
It does this by opening up two data structures. Primarily is the command_receiver,
and mpsc (multi-channel-single-producer) which allows commands to be sent from
the Interface, and queued up for the Manager to process.
These have been mapped in the DownloadManagerSignal docs.
The other way to interact with the DownloadManager is via the donwload_queue,
which is just a collection of ids which may be rearranged to suit
whichever download queue order is required.
+----------------------------------------------------------------------------+
| DO NOT ATTEMPT TO ADD OR REMOVE FROM THE QUEUE WITHOUT USING SIGNALS!! |
| THIS WILL CAUSE A DESYNC BETWEEN THE DOWNLOAD AGENT REGISTRY AND THE QUEUE |
| WHICH HAS NOT BEEN ACCOUNTED FOR |
+----------------------------------------------------------------------------+
This download queue does not actually own any of the GameDownloadAgents. It is
simply a id-based reference system. The actual Agents are stored in the
download_agent_registry HashMap, as ordering is no issue here. This is why
appending or removing from the download_queue must be done via signals.
Behold, my madness - quexeky
*/
// Refactored to consolidate this type. It's a monster.
pub type CurrentProgressObject = Arc<Mutex<Option<Arc<ProgressObject>>>>;
pub struct DownloadManagerBuilder {
download_agent_registry: HashMap<String, Arc<GameDownloadAgent>>,
download_queue: Queue,
command_receiver: Receiver<DownloadManagerSignal>,
sender: Sender<DownloadManagerSignal>,
progress: CurrentProgressObject,
status: Arc<Mutex<DownloadManagerStatus>>,
app_handle: AppHandle,
current_download_agent: Option<Arc<GameDownloadAgentQueueStandin>>, // Should be the only game download agent in the map with the "Go" flag
current_download_thread: Mutex<Option<JoinHandle<()>>>,
active_control_flag: Option<DownloadThreadControl>,
}
impl DownloadManagerBuilder {
pub fn build(app_handle: AppHandle) -> DownloadManager {
let queue = Queue::new();
let (command_sender, command_receiver) = channel();
let active_progress = Arc::new(Mutex::new(None));
let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty));
let manager = Self {
download_agent_registry: HashMap::new(),
download_queue: queue.clone(),
command_receiver,
status: status.clone(),
sender: command_sender.clone(),
progress: active_progress.clone(),
app_handle,
current_download_agent: None,
current_download_thread: Mutex::new(None),
active_control_flag: None,
};
let terminator = spawn(|| manager.manage_queue());
DownloadManager::new(terminator, queue, active_progress, command_sender)
}
fn set_game_status(&self, id: String, status: DatabaseGameStatus) {
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.games
.games_statuses
.insert(id.clone(), status.clone());
drop(db_handle);
DB.save().unwrap();
self.app_handle
.emit(
&format!("update_game/{}", id),
GameUpdateEvent {
game_id: id,
status,
},
)
.unwrap();
}
fn push_manager_update(&self) {
let queue = self.download_queue.read();
let queue_objs: Vec<QueueUpdateEventQueueData> = queue
.iter()
.map(|interface| QueueUpdateEventQueueData {
id: interface.id.clone(),
status: interface.status.lock().unwrap().clone(),
progress: interface.progress.get_progress(),
})
.collect();
let event_data = QueueUpdateEvent { queue: queue_objs };
self.app_handle.emit("update_queue", event_data).unwrap();
}
fn remove_and_cleanup_game(&mut self, game_id: &String) -> Arc<GameDownloadAgent> {
self.download_queue.pop_front();
let download_agent = self.download_agent_registry.remove(game_id).unwrap();
self.cleanup_current_download();
download_agent
}
// CAREFUL WITH THIS FUNCTION
// Make sure the download thread is terminated
fn cleanup_current_download(&mut self) {
self.active_control_flag = None;
*self.progress.lock().unwrap() = None;
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = None;
drop(download_thread_lock);
}
fn manage_queue(mut self) -> Result<(), ()> {
loop {
let signal = match self.command_receiver.recv() {
Ok(signal) => signal,
Err(_) => return Err(()),
};
match signal {
DownloadManagerSignal::Go => {
self.manage_go_signal();
}
DownloadManagerSignal::Stop => {
self.manage_stop_signal();
}
DownloadManagerSignal::Completed(game_id) => {
self.manage_completed_signal(game_id);
}
DownloadManagerSignal::Queue(game_id, version, target_download_dir) => {
self.manage_queue_signal(game_id, version, target_download_dir);
}
DownloadManagerSignal::Finish => {
if let Some(active_control_flag) = self.active_control_flag {
active_control_flag.set(DownloadThreadControlFlag::Stop)
}
return Ok(());
}
DownloadManagerSignal::Error(e) => {
self.manage_error_signal(e);
}
DownloadManagerSignal::Cancel => {
self.manage_cancel_signal();
}
DownloadManagerSignal::Update => {
self.push_manager_update();
}
};
}
}
fn manage_stop_signal(&mut self) {
info!("Got signal 'Stop'");
if let Some(active_control_flag) = self.active_control_flag.clone() {
active_control_flag.set(DownloadThreadControlFlag::Stop);
}
}
fn manage_completed_signal(&mut self, game_id: String) {
info!("Got signal 'Completed'");
if let Some(interface) = &self.current_download_agent {
// When if let chains are stabilised, combine these two statements
if interface.id == game_id {
info!("Popping consumed data");
let download_agent = self.remove_and_cleanup_game(&game_id);
if let Err(error) =
on_game_complete(game_id, download_agent.version.clone(), &self.app_handle)
{
self.sender
.send(DownloadManagerSignal::Error(
GameDownloadError::Communication(error),
))
.unwrap();
}
}
}
self.sender.send(DownloadManagerSignal::Update).unwrap();
self.sender.send(DownloadManagerSignal::Go).unwrap();
}
fn manage_queue_signal(&mut self, id: String, version: String, target_download_dir: usize) {
info!("Got signal Queue");
let download_agent = Arc::new(GameDownloadAgent::new(
id.clone(),
version,
target_download_dir,
self.sender.clone(),
));
let agent_status = GameDownloadStatus::Queued;
let interface_data = GameDownloadAgentQueueStandin {
id: id.clone(),
status: Mutex::new(agent_status),
progress: download_agent.progress.clone(),
};
let version_name = download_agent.version.clone();
self.download_agent_registry
.insert(interface_data.id.clone(), download_agent);
self.download_queue.append(interface_data);
self.set_game_status(id, DatabaseGameStatus::Queued { version_name });
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
fn manage_go_signal(&mut self) {
if !(!self.download_agent_registry.is_empty() && !self.download_queue.empty()) {
return;
}
if self.current_download_agent.is_some() {
info!("skipping go signal due to existing download job");
return;
}
info!("current download queue: {:?}", self.download_queue.read());
let agent_data = self.download_queue.read().front().unwrap().clone();
info!("starting download for {}", agent_data.id.clone());
let download_agent = self
.download_agent_registry
.get(&agent_data.id)
.unwrap()
.clone();
self.current_download_agent = Some(agent_data);
// Cloning option should be okay because it only clones the Arc inside, not the AgentInterfaceData
let agent_data = self.current_download_agent.clone().unwrap();
let version_name = download_agent.version.clone();
let progress_object = download_agent.progress.clone();
*self.progress.lock().unwrap() = Some(progress_object);
let active_control_flag = download_agent.control_flag.clone();
self.active_control_flag = Some(active_control_flag.clone());
let sender = self.sender.clone();
info!("Spawning download");
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = Some(spawn(move || {
match download_agent.download() {
// Returns once we've exited the download
// (not necessarily completed)
// The download agent will fire the completed event for us
Ok(_) => {}
// If an error occurred while *starting* the download
Err(err) => {
error!("error while managing download: {}", err);
sender.send(DownloadManagerSignal::Error(err)).unwrap();
}
};
}));
// Set status for game
let mut status_handle = agent_data.status.lock().unwrap();
*status_handle = GameDownloadStatus::Downloading;
// Set flags for download manager
active_control_flag.set(DownloadThreadControlFlag::Go);
self.set_status(DownloadManagerStatus::Downloading);
self.set_game_status(
self.current_download_agent.as_ref().unwrap().id.clone(),
DatabaseGameStatus::Downloading { version_name },
);
}
fn manage_error_signal(&mut self, error: GameDownloadError) {
let current_status = self.current_download_agent.clone().unwrap();
self.remove_and_cleanup_game(&current_status.id); // Remove all the locks and shit
let mut lock = current_status.status.lock().unwrap();
*lock = GameDownloadStatus::Error;
self.set_status(DownloadManagerStatus::Error(error));
let game_id = self.current_download_agent.as_ref().unwrap().id.clone();
self.set_game_status(game_id, DatabaseGameStatus::Remote {});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
fn manage_cancel_signal(&mut self) {
if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop);
}
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
drop(download_thread_lock);
info!("cancel waited for download to finish");
self.cleanup_current_download();
}
fn set_status(&self, status: DownloadManagerStatus) {
*self.status.lock().unwrap() = status;
}
}
@@ -4,11 +4,13 @@ use std::path::PathBuf;
pub type DropManifest = HashMap<String, DropChunk>;
#[derive(Serialize, Deserialize, Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DropChunk {
pub permissions: usize,
pub ids: Vec<String>,
pub checksums: Vec<String>,
pub lengths: Vec<usize>,
pub version_name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -20,4 +22,5 @@ pub struct DropDownloadContext {
pub game_id: String,
pub path: PathBuf,
pub checksum: String,
pub length: usize,
}
+4 -1
View File
@@ -1,6 +1,9 @@
pub mod download_agent;
pub mod download_commands;
mod download_logic;
pub mod download_manager;
pub mod download_manager_builder;
mod download_thread_control_flag;
mod manifest;
mod progress_object;
mod progress_object;
pub mod queue;
@@ -1,33 +1,111 @@
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
use std::{
sync::{
atomic::{AtomicUsize, Ordering},
mpsc::Sender,
Arc, Mutex,
},
time::Instant,
};
use log::info;
use super::download_manager::DownloadManagerSignal;
#[derive(Clone)]
pub struct ProgressObject {
max: usize,
progress_instances: Arc<Vec<Arc<AtomicUsize>>>,
max: Arc<Mutex<usize>>,
progress_instances: Arc<Mutex<Vec<Arc<AtomicUsize>>>>,
start: Arc<Mutex<Instant>>,
sender: Sender<DownloadManagerSignal>,
points_towards_update: Arc<AtomicUsize>,
points_to_push_update: Arc<Mutex<usize>>,
}
impl ProgressObject {
pub fn new(max: usize, length: usize) -> Self {
let arr = (0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect();
pub struct ProgressHandle {
progress: Arc<AtomicUsize>,
progress_object: Arc<ProgressObject>,
}
impl ProgressHandle {
pub fn new(progress: Arc<AtomicUsize>, progress_object: Arc<ProgressObject>) -> Self {
Self {
max,
progress_instances: Arc::new(arr),
progress,
progress_object,
}
}
pub fn set(&self, amount: usize) {
self.progress.store(amount, Ordering::Relaxed);
}
pub fn add(&self, amount: usize) {
self.progress
.fetch_add(amount, std::sync::atomic::Ordering::Relaxed);
self.progress_object.check_push_update(amount);
}
}
static PROGRESS_UPDATES: usize = 100;
impl ProgressObject {
pub fn new(max: usize, length: usize, sender: Sender<DownloadManagerSignal>) -> Self {
let arr = Mutex::new((0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect());
// TODO: consolidate this calculation with the set_max function below
let points_to_push_update = max / PROGRESS_UPDATES;
Self {
max: Arc::new(Mutex::new(max)),
progress_instances: Arc::new(arr),
start: Arc::new(Mutex::new(Instant::now())),
sender,
points_towards_update: Arc::new(AtomicUsize::new(0)),
points_to_push_update: Arc::new(Mutex::new(points_to_push_update)),
}
}
pub fn check_push_update(&self, amount_added: usize) {
let current_amount = self
.points_towards_update
.fetch_add(amount_added, Ordering::Relaxed);
let to_update_handle = self.points_to_push_update.lock().unwrap();
let to_update = *to_update_handle;
drop(to_update_handle);
if current_amount < to_update {
return;
}
self.points_towards_update
.fetch_sub(to_update, Ordering::Relaxed);
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
pub fn set_time_now(&self) {
*self.start.lock().unwrap() = Instant::now();
}
pub fn sum(&self) -> usize {
self.progress_instances
.lock()
.unwrap()
.iter()
.map(|instance| instance.load(Ordering::Relaxed))
.sum()
}
pub fn get_max(&self) -> usize {
*self.max.lock().unwrap()
}
pub fn set_max(&self, new_max: usize) {
*self.max.lock().unwrap() = new_max;
*self.points_to_push_update.lock().unwrap() = new_max / PROGRESS_UPDATES;
info!("points to push update: {}", new_max / PROGRESS_UPDATES);
}
pub fn set_size(&self, length: usize) {
*self.progress_instances.lock().unwrap() =
(0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect();
}
pub fn get_progress(&self) -> f64 {
self.sum() as f64 / self.max as f64
self.sum() as f64 / self.get_max() as f64
}
pub fn get(&self, index: usize) -> Arc<AtomicUsize> {
self.progress_instances[index].clone()
self.progress_instances.lock().unwrap()[index].clone()
}
}
+73
View File
@@ -0,0 +1,73 @@
use std::{
collections::VecDeque,
sync::{Arc, Mutex, MutexGuard},
};
use super::download_manager::GameDownloadAgentQueueStandin;
#[derive(Clone)]
pub struct Queue {
inner: Arc<Mutex<VecDeque<Arc<GameDownloadAgentQueueStandin>>>>,
}
#[allow(dead_code)]
impl Queue {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(VecDeque::new())),
}
}
pub fn read(&self) -> VecDeque<Arc<GameDownloadAgentQueueStandin>> {
self.inner.lock().unwrap().clone()
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>> {
self.inner.lock().unwrap()
}
pub fn pop_front(&self) -> Option<Arc<GameDownloadAgentQueueStandin>> {
self.edit().pop_front()
}
pub fn empty(&self) -> bool {
self.inner.lock().unwrap().len() == 0
}
/// Either inserts `interface` at the specified index, or appends to
/// the back of the deque if index is greater than the length of the deque
pub fn insert(&self, interface: GameDownloadAgentQueueStandin, index: usize) {
if self.read().len() > index {
self.append(interface);
} else {
self.edit().insert(index, Arc::new(interface));
}
}
pub fn append(&self, interface: GameDownloadAgentQueueStandin) {
self.edit().push_back(Arc::new(interface));
}
pub fn pop_front_if_equal(
&self,
game_id: String,
) -> Option<Arc<GameDownloadAgentQueueStandin>> {
let mut queue = self.edit();
let front = match queue.front() {
Some(front) => front,
None => return None,
};
if front.id == game_id {
return queue.pop_front();
}
None
}
pub fn get_by_id(&self, game_id: String) -> Option<usize> {
self.read().iter().position(|data| data.id == game_id)
}
pub fn move_to_index_by_id(&self, game_id: String, new_index: usize) -> Result<(), ()> {
let index = match self.get_by_id(game_id) {
Some(index) => index,
None => return Err(()),
};
let existing = match self.edit().remove(index) {
Some(existing) => existing,
None => return Err(()),
};
self.edit().insert(new_index, existing);
Ok(())
}
}
+40 -21
View File
@@ -2,21 +2,25 @@ mod auth;
mod db;
mod downloads;
mod library;
mod p2p;
// mod p2p;
mod remote;
mod settings;
#[cfg(test)]
mod tests;
use crate::db::DatabaseImpls;
use crate::downloads::download_agent::GameDownloadAgent;
use auth::{auth_initiate, generate_authorization_header, recieve_handshake};
use db::{add_new_download_dir, DatabaseInterface, DATA_ROOT_DIR};
use auth::{auth_initiate, generate_authorization_header, recieve_handshake, retry_connect};
use db::{
add_download_dir, delete_download_dir, fetch_download_dir_stats, DatabaseInterface,
DATA_ROOT_DIR,
};
use downloads::download_commands::*;
use downloads::download_manager::DownloadManager;
use downloads::download_manager_builder::DownloadManagerBuilder;
use http::{header::*, response::Builder as ResponseBuilder};
use library::{fetch_game, fetch_library, Game};
use log::{info, LevelFilter};
use log4rs::append::console::{ConsoleAppender, Target};
use library::{fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, Game};
use log::{debug, info, LevelFilter};
use log4rs::append::console::ConsoleAppender;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Root};
use log4rs::encode::pattern::PatternEncoder;
@@ -28,6 +32,7 @@ use std::{
collections::HashMap,
sync::{LazyLock, Mutex},
};
use tauri::{AppHandle, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
#[derive(Clone, Copy, Serialize)]
@@ -39,6 +44,7 @@ pub enum AppStatus {
SignedInNeedsReauth,
ServerUnavailable,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
@@ -57,7 +63,7 @@ pub struct AppState {
games: HashMap<String, Game>,
#[serde(skip_serializing)]
game_downloads: HashMap<String, Arc<GameDownloadAgent>>,
download_manager: Arc<DownloadManager>,
}
#[tauri::command]
@@ -68,14 +74,14 @@ fn fetch_state(state: tauri::State<'_, Mutex<AppState>>) -> Result<AppState, Str
Ok(cloned_state)
}
fn setup() -> AppState {
fn setup(handle: AppHandle) -> AppState {
let logfile = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new("{t}|{l}|{f} - {m}{n}")))
.build(DATA_ROOT_DIR.lock().unwrap().join("./drop.log"))
.unwrap();
let console = ConsoleAppender::builder()
.encoder(Box::new(PatternEncoder::new("{l} - {m}\n")))
.encoder(Box::new(PatternEncoder::new("{t}|{l}|{f} - {m}{n}\n")))
.build();
let config = Config::builder()
@@ -92,24 +98,28 @@ fn setup() -> AppState {
log4rs::init_config(config).unwrap();
//env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let games = HashMap::new();
let download_manager = Arc::new(DownloadManagerBuilder::build(handle));
debug!("Checking if database is set up");
let is_set_up = DB.database_is_set_up();
if !is_set_up {
return AppState {
status: AppStatus::NotConfigured,
user: None,
games: HashMap::new(),
game_downloads: HashMap::new(),
games,
download_manager,
};
}
debug!("Database is set up");
let (app_status, user) = auth::setup().unwrap();
AppState {
status: app_status,
user,
games: HashMap::new(),
game_downloads: HashMap::new(),
games,
download_manager,
}
}
@@ -117,9 +127,6 @@ pub static DB: LazyLock<DatabaseInterface> = LazyLock::new(DatabaseInterface::se
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let state = setup();
info!("initialized drop client");
let mut builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init());
#[cfg(desktop)]
@@ -132,25 +139,37 @@ pub fn run() {
builder
.plugin(tauri_plugin_deep_link::init())
.manage(Mutex::new(state))
.invoke_handler(tauri::generate_handler![
// DB
fetch_state,
// Auth
auth_initiate,
retry_connect,
// Remote
use_remote,
gen_drop_url,
// Library
fetch_library,
fetch_game,
add_new_download_dir,
add_download_dir,
delete_download_dir,
fetch_download_dir_stats,
fetch_game_status,
fetch_game_verion_options,
// Downloads
download_game,
get_game_download_progress,
move_game_in_queue,
pause_game_downloads,
resume_game_downloads,
])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let handle = app.handle().clone();
let state = setup(handle);
info!("initialized drop client");
app.manage(Mutex::new(state));
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
use tauri_plugin_deep_link::DeepLinkExt;
+195 -21
View File
@@ -1,16 +1,19 @@
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::Emitter;
use tauri::{AppHandle, Manager};
use urlencoding::encode;
use crate::db::DatabaseGameStatus;
use crate::db::DatabaseImpls;
use crate::db::GameVersion;
use crate::downloads::download_manager::GameDownloadStatus;
use crate::remote::RemoteAccessError;
use crate::{auth::generate_authorization_header, AppState, DB};
#[derive(serde::Serialize)]
struct FetchGameStruct {
pub struct FetchGameStruct {
game: Game,
status: DatabaseGameStatus,
}
@@ -29,8 +32,38 @@ pub struct Game {
m_cover_id: String,
m_image_library: Vec<String>,
}
#[derive(serde::Serialize, Clone)]
pub struct GameUpdateEvent {
pub game_id: String,
pub status: DatabaseGameStatus,
}
fn fetch_library_logic(app: AppHandle) -> Result<String, RemoteAccessError> {
#[derive(Serialize, Clone)]
pub struct QueueUpdateEventQueueData {
pub id: String,
pub status: GameDownloadStatus,
pub progress: f64,
}
#[derive(serde::Serialize, Clone)]
pub struct QueueUpdateEvent {
pub queue: Vec<QueueUpdateEventQueueData>,
}
// Game version with some fields missing and size information
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameVersionOption {
version_index: usize,
version_name: String,
platform: String,
setup_command: String,
launch_command: String,
delta: bool,
// total_size: usize,
}
fn fetch_library_logic(app: AppHandle) -> Result<Vec<Game>, RemoteAccessError> {
let base_url = DB.fetch_base_url();
let library_url = base_url.join("/api/v1/client/user/library")?;
@@ -46,7 +79,7 @@ fn fetch_library_logic(app: AppHandle) -> Result<String, RemoteAccessError> {
return Err(response.status().as_u16().into());
}
let games = response.json::<Vec<Game>>()?;
let games: Vec<Game> = response.json::<Vec<Game>>()?;
let state = app.state::<Mutex<AppState>>();
let mut handle = state.lock().unwrap();
@@ -59,31 +92,28 @@ fn fetch_library_logic(app: AppHandle) -> Result<String, RemoteAccessError> {
db_handle
.games
.games_statuses
.insert(game.id.clone(), DatabaseGameStatus::Remote);
.insert(game.id.clone(), DatabaseGameStatus::Remote {});
}
}
drop(handle);
Ok(json!(games.clone()).to_string())
Ok(games)
}
#[tauri::command]
pub fn fetch_library(app: AppHandle) -> Result<String, String> {
let result = fetch_library_logic(app);
if result.is_err() {
return Err(result.err().unwrap().to_string());
}
Ok(result.unwrap())
pub fn fetch_library(app: AppHandle) -> Result<Vec<Game>, String> {
fetch_library_logic(app).map_err(|e| e.to_string())
}
fn fetch_game_logic(id: String, app: tauri::AppHandle) -> Result<String, RemoteAccessError> {
fn fetch_game_logic(
id: String,
app: tauri::AppHandle,
) -> Result<FetchGameStruct, RemoteAccessError> {
let state = app.state::<Mutex<AppState>>();
let handle = state.lock().unwrap();
let mut state_handle = state.lock().unwrap();
let game = handle.games.get(&id);
let game = state_handle.games.get(&id);
if let Some(game) = game {
let db_handle = DB.borrow_data().unwrap();
@@ -97,15 +127,55 @@ fn fetch_game_logic(id: String, app: tauri::AppHandle) -> Result<String, RemoteA
.clone(),
};
return Ok(json!(data).to_string());
return Ok(data);
}
// TODO request games that aren't found from remote server
Err("".to_string().into())
let base_url = DB.fetch_base_url();
let endpoint = base_url.join(&format!("/api/v1/game/{}", id))?;
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
let response = client
.get(endpoint.to_string())
.header("Authorization", header)
.send()?;
if response.status() == 404 {
return Err(RemoteAccessError::GameNotFound);
}
if response.status() != 200 {
return Err(RemoteAccessError::InvalidCodeError(
response.status().into(),
));
}
let game = response.json::<Game>()?;
state_handle.games.insert(id.clone(), game.clone());
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.games
.games_statuses
.entry(id)
.or_insert(DatabaseGameStatus::Remote {});
let data = FetchGameStruct {
game: game.clone(),
status: db_handle
.games
.games_statuses
.get(&game.id)
.unwrap()
.clone(),
};
Ok(data)
}
#[tauri::command]
pub fn fetch_game(id: String, app: tauri::AppHandle) -> Result<String, String> {
pub fn fetch_game(id: String, app: tauri::AppHandle) -> Result<FetchGameStruct, String> {
let result = fetch_game_logic(id, app);
if result.is_err() {
@@ -114,3 +184,107 @@ pub fn fetch_game(id: String, app: tauri::AppHandle) -> Result<String, String> {
Ok(result.unwrap())
}
#[tauri::command]
pub fn fetch_game_status(id: String) -> Result<DatabaseGameStatus, String> {
let db_handle = DB.borrow_data().unwrap();
let status = db_handle
.games
.games_statuses
.get(&id)
.unwrap_or(&DatabaseGameStatus::Remote {})
.clone();
drop(db_handle);
Ok(status)
}
fn fetch_game_verion_options_logic(
game_id: String,
) -> Result<Vec<GameVersionOption>, RemoteAccessError> {
let base_url = DB.fetch_base_url();
let endpoint =
base_url.join(format!("/api/v1/client/metadata/versions?id={}", game_id).as_str())?;
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
let response = client
.get(endpoint.to_string())
.header("Authorization", header)
.send()?;
if response.status() != 200 {
return Err(RemoteAccessError::InvalidCodeError(
response.status().into(),
));
}
let data = response.json::<Vec<GameVersionOption>>()?;
Ok(data)
}
#[tauri::command]
pub fn fetch_game_verion_options(game_id: String) -> Result<Vec<GameVersionOption>, String> {
fetch_game_verion_options_logic(game_id).map_err(|e| e.to_string())
}
pub fn on_game_complete(
game_id: String,
version_name: String,
app_handle: &AppHandle,
) -> Result<(), RemoteAccessError> {
// Fetch game version information from remote
let base_url = DB.fetch_base_url();
let endpoint = base_url.join(
format!(
"/api/v1/client/metadata/version?id={}&version={}",
game_id,
encode(&version_name)
)
.as_str(),
)?;
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
let response = client
.get(endpoint.to_string())
.header("Authorization", header)
.send()?;
let data = response.json::<GameVersion>()?;
let mut handle = DB.borrow_data_mut().unwrap();
handle
.games
.game_versions
.entry(game_id.clone())
.or_default()
.insert(version_name.clone(), data.clone());
drop(handle);
DB.save().unwrap();
let status = if data.setup_command.is_empty() {
DatabaseGameStatus::Installed { version_name }
} else {
DatabaseGameStatus::SetupRequired { version_name }
};
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.games
.games_statuses
.insert(game_id.clone(), status.clone());
drop(db_handle);
DB.save().unwrap();
app_handle
.emit(
&format!("update_game/{}", game_id),
GameUpdateEvent { game_id, status },
)
.unwrap();
Ok(())
}
+17
View File
@@ -0,0 +1,17 @@
use crate::{auth::generate_authorization_header, db::DatabaseImpls, remote::RemoteAccessError, DB};
pub async fn register() -> Result<String, RemoteAccessError> {
let base_url = DB.fetch_base_url();
let registration_url = base_url.join("/api/v1/client/capability").unwrap();
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
client
.post(registration_url)
.header("Authorization", header)
.send()?;
return Ok(String::new())
}
+22 -12
View File
@@ -1,43 +1,53 @@
use std::{
fmt::{Display, Formatter},
sync::Mutex,
sync::{Arc, Mutex},
};
use http::StatusCode;
use log::{info, warn};
use serde::Deserialize;
use url::{ParseError, Url};
use crate::{AppState, AppStatus, DB};
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum RemoteAccessError {
FetchError(reqwest::Error),
FetchError(Arc<reqwest::Error>),
ParsingError(ParseError),
InvalidCodeError(u16),
GenericErrror(String),
InvalidEndpoint,
HandshakeFailed,
GameNotFound,
InvalidResponse,
InvalidRedirect,
ManifestDownloadFailed(StatusCode, String),
}
impl Display for RemoteAccessError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
RemoteAccessError::FetchError(error) => write!(f, "{}", error),
RemoteAccessError::GenericErrror(error) => write!(f, "{}", error),
RemoteAccessError::ParsingError(parse_error) => {
write!(f, "{}", parse_error)
}
RemoteAccessError::InvalidCodeError(error) => write!(f, "HTTP {}", error),
RemoteAccessError::InvalidEndpoint => write!(f, "Invalid drop endpoint"),
RemoteAccessError::HandshakeFailed => write!(f, "Failed to complete handshake"),
RemoteAccessError::GameNotFound => write!(f, "Could not find game on server"),
RemoteAccessError::InvalidResponse => write!(f, "Server returned an invalid response"),
RemoteAccessError::InvalidRedirect => write!(f, "Server redirect was invalid"),
RemoteAccessError::ManifestDownloadFailed(status, response) => write!(
f,
"Failed to download game manifest: {} {}",
status, response
),
}
}
}
impl From<reqwest::Error> for RemoteAccessError {
fn from(err: reqwest::Error) -> Self {
RemoteAccessError::FetchError(err)
}
}
impl From<String> for RemoteAccessError {
fn from(err: String) -> Self {
RemoteAccessError::GenericErrror(err)
RemoteAccessError::FetchError(Arc::new(err))
}
}
impl From<ParseError> for RemoteAccessError {
@@ -74,7 +84,7 @@ async fn use_remote_logic<'a>(
if result.app_name != "Drop" {
warn!("user entered drop endpoint that connected, but wasn't identified as Drop");
return Err("Not a valid Drop endpoint".to_string().into());
return Err(RemoteAccessError::InvalidEndpoint);
}
let mut app_state = state.lock().unwrap();
+28 -2
View File
@@ -1,4 +1,3 @@
import type { User } from "@prisma/client";
import type { Component } from "vue";
export type NavigationItem = {
@@ -12,11 +11,31 @@ export type QuickActionNav = {
notifications?: number;
action: () => Promise<void>;
};
export type User = {
id: string;
username: string;
admin: boolean;
displayName: string;
profilePicture: string;
};
export type AppState = {
status: AppStatus;
user?: User;
};
export type Game = {
id: string;
mName: string;
mShortDescription: string;
mDescription: string;
mIconId: string;
mBannerId: string;
mCoverId: string;
mImageLibrary: string[];
};
export enum AppStatus {
NotConfigured = "NotConfigured",
SignedOut = "SignedOut",
@@ -25,10 +44,17 @@ export enum AppStatus {
ServerUnavailable = "ServerUnavailable",
}
export enum GameStatus {
export enum GameStatusEnum {
Remote = "Remote",
Queued = "Queued",
Downloading = "Downloading",
Installed = "Installed",
Updating = "Updating",
Uninstalling = "Uninstalling",
SetupRequired = "SetupRequired",
}
export type GameStatus = {
type: GameStatusEnum;
version_name?: string;
};
+17 -55
View File
@@ -1075,47 +1075,6 @@
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73"
integrity sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==
"@prisma/client@5.20.0":
version "5.20.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.20.0.tgz#4fc9f2b2341c9c997c139df4445688dd6b39663b"
integrity sha512-CLv55ZuMuUawMsxoqxGtLT3bEZoa2W8L3Qnp6rDIFWy+ZBrUcOFKdoeGPSnbBqxc3SkdxJrF+D1veN/WNynZYA==
"@prisma/debug@5.20.0":
version "5.20.0"
resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-5.20.0.tgz#c6d1cf6e3c6e9dba150347f13ca200b1d66cc9fc"
integrity sha512-oCx79MJ4HSujokA8S1g0xgZUGybD4SyIOydoHMngFYiwEwYDQ5tBQkK5XoEHuwOYDKUOKRn/J0MEymckc4IgsQ==
"@prisma/engines-version@5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284":
version "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284.tgz#9a53b13cdcfd706ae54198111000f33c63655c39"
integrity sha512-Lg8AS5lpi0auZe2Mn4gjuCg081UZf88k3cn0RCwHgR+6cyHHpttPZBElJTHf83ZGsRNAmVCZCfUGA57WB4u4JA==
"@prisma/engines@5.20.0":
version "5.20.0"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.20.0.tgz#86fe407e55219d33d03ebc26dc829a422faed545"
integrity sha512-DtqkP+hcZvPEbj8t8dK5df2b7d3B8GNauKqaddRRqQBBlgkbdhJkxhoJTrOowlS3vaRt2iMCkU0+CSNn0KhqAQ==
dependencies:
"@prisma/debug" "5.20.0"
"@prisma/engines-version" "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284"
"@prisma/fetch-engine" "5.20.0"
"@prisma/get-platform" "5.20.0"
"@prisma/fetch-engine@5.20.0":
version "5.20.0"
resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-5.20.0.tgz#b917880fb08f654981f14ca49923031b39683586"
integrity sha512-JVcaPXC940wOGpCOwuqQRTz6I9SaBK0c1BAyC1pcz9xBi+dzFgUu3G/p9GV1FhFs9OKpfSpIhQfUJE9y00zhqw==
dependencies:
"@prisma/debug" "5.20.0"
"@prisma/engines-version" "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284"
"@prisma/get-platform" "5.20.0"
"@prisma/get-platform@5.20.0":
version "5.20.0"
resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-5.20.0.tgz#c1a53a8d8af67f2b4a6b97dd4d25b1c603236804"
integrity sha512-8/+CehTZZNzJlvuryRgc77hZCWrUDYd/PmlZ7p2yNXtmf2Una4BWnTbak3us6WVdqoz5wmptk6IhsXdG2v5fmA==
dependencies:
"@prisma/debug" "5.20.0"
"@rollup/plugin-alias@^5.1.0":
version "5.1.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz#53601d88cda8b1577aa130b4a6e452283605bf26"
@@ -1376,10 +1335,10 @@
dependencies:
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-dialog@~2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.0.tgz#f1e2840c7f824572a76b375fd1b538a36f28de14"
integrity sha512-ApNkejXP2jpPBSifznPPcHTXxu9/YaRW+eJ+8+nYwqp0lLUtebFHG4QhxitM43wwReHE81WAV1DQ/b+2VBftOA==
"@tauri-apps/plugin-dialog@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.0.1.tgz#cca38f2ef361c6d92495f5aa12154492cf3fa779"
integrity sha512-fnUrNr6EfvTqdls/ufusU7h6UbNFzLKvHk/zTuOiBq01R3dTODqwctZlzakdbfSp/7pNwTKvgKTAgl/NAP/Z0Q==
dependencies:
"@tauri-apps/api" "^2.0.0"
@@ -2816,7 +2775,7 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@2.3.3, fsevents@~2.3.2, fsevents@~2.3.3:
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
@@ -4345,15 +4304,6 @@ pretty-bytes@^6.1.1:
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==
prisma@^5.20.0:
version "5.20.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.20.0.tgz#f2ab266a0d59383506886e7acbff0dbf322f4c7e"
integrity sha512-6obb3ucKgAnsGS9x9gLOe8qa51XxvJ3vLQtmyf52CTey1Qcez3A6W6ROH5HIz5Q5bW+0VpmZb8WBohieMFGpig==
dependencies:
"@prisma/engines" "5.20.0"
optionalDependencies:
fsevents "2.3.3"
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -4847,6 +4797,11 @@ smob@^1.0.0:
resolved "https://registry.yarnpkg.com/smob/-/smob-1.5.0.tgz#85d79a1403abf128d24d3ebc1cdc5e1a9548d3ab"
integrity sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==
sortablejs@1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.14.0.tgz#6d2e17ccbdb25f464734df621d4f35d4ab35b3d8"
integrity sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==
source-map-js@^1.0.1, source-map-js@^1.2.0, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
@@ -5553,6 +5508,13 @@ vue@^3.5.5, vue@latest:
"@vue/server-renderer" "3.5.11"
"@vue/shared" "3.5.11"
vuedraggable@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vuedraggable/-/vuedraggable-4.1.0.tgz#edece68adb8a4d9e06accff9dfc9040e66852270"
integrity sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==
dependencies:
sortablejs "1.14.0"
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"