merge(download-manager) -> 'main'
feat(downloads): Added Download Manager See merge request drop-oss/drop-app!1
This commit is contained in:
+4
-1
@@ -23,4 +23,7 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
.nuxt
|
||||
.output
|
||||
.output
|
||||
|
||||
src-tauri/flamegraph.svg
|
||||
src-tauri/perf*
|
||||
@@ -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
@@ -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`:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -6,3 +6,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const queueState = useQueueState();
|
||||
</script>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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">→</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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import draggable from "vuedraggable";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component("draggable", draggable);
|
||||
});
|
||||
@@ -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")
|
||||
}
|
||||
Generated
+874
-418
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(©_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(¤t_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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user