Compare commits
5 Commits
136-native
...
0147956b5f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0147956b5f | |||
| cc57ca7076 | |||
| 70cecdad19 | |||
| 3f18d15d39 | |||
| 97b5cd5e78 |
24
README.md
@ -1,29 +1,21 @@
|
||||
# Drop App
|
||||
# Drop Desktop Client
|
||||
|
||||
Drop app is the companion app for [Drop](https://github.com/Drop-OSS/drop). It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
|
||||
The Drop Desktop Client is the companion app for [Drop](https://github.com/Drop-OSS/drop). It is the official & intended way to download and play games on your Drop server.
|
||||
|
||||
## Running
|
||||
Before setting up the drop app, be sure that you have a server set up.
|
||||
The instructions for this can be found on the [Drop Docs](https://docs.droposs.org/docs/guides/quickstart)
|
||||
## Internals
|
||||
|
||||
## Current features
|
||||
Currently supported are the following features:
|
||||
- Signin (with custom server)
|
||||
- Database registering & recovery
|
||||
- Dynamic library fetching from server
|
||||
- Installing & uninstalling games
|
||||
- Download progress monitoring
|
||||
- Launching / playing games
|
||||
It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
|
||||
|
||||
## Development
|
||||
Before setting up a development environemnt, be sure that you have a server set up. The instructions for this can be found on the [Drop Docs](https://docs.droposs.org/docs/guides/quickstart).
|
||||
|
||||
Install dependencies with `yarn`
|
||||
Then, install dependencies with `yarn`. This'll install the custom builder's dependencies. Then, check everything works properly with `yarn tauri build`.
|
||||
|
||||
Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use shell script `./nvidia-prop-dev.sh`
|
||||
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`:
|
||||
|
||||
e.g. `RUST_LOG=debug yarn tauri dev`
|
||||
|
||||
## Contributing
|
||||
Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines.
|
||||
Check out the contributing guide on our Developer Docs: [Drop Developer Docs - Contributing](https://developer.droposs.org/contributing).
|
||||
|
||||
@ -21,6 +21,13 @@ async function spawn(exec, opts) {
|
||||
});
|
||||
}
|
||||
|
||||
const expectedLibs = ["drop-base/package.json"];
|
||||
|
||||
for (const lib of expectedLibs) {
|
||||
const path = `./libs/${lib}`;
|
||||
if (!fs.existsSync(path)) throw `Missing "${expectedLibs}". Run "git submodule update --init --recursive"`;
|
||||
}
|
||||
|
||||
const views = fs.readdirSync(".").filter((view) => {
|
||||
const expectedPath = `./${view}/package.json`;
|
||||
return fs.existsSync(expectedPath);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<LoadingIndicator />
|
||||
<NuxtLoadingIndicator color="#2563eb" />
|
||||
<NuxtLayout class="select-none w-screen h-screen">
|
||||
<NuxtPage />
|
||||
<ModalStack />
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
</NuxtLink>
|
||||
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
|
||||
<div class="flex flex-col mb-1">
|
||||
<MenuItem v-slot="{ active }">
|
||||
<MenuItem v-if="state.user.admin" v-slot="{ active }">
|
||||
<a
|
||||
:href="adminUrl"
|
||||
target="_blank"
|
||||
|
||||
@ -1,55 +1,118 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="mb-3 inline-flex gap-x-2">
|
||||
<div class="relative transition-transform duration-300 hover:scale-105 active:scale-95">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<MagnifyingGlassIcon class="h-5 w-5 text-zinc-400" aria-hidden="true" />
|
||||
<div
|
||||
class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
|
||||
>
|
||||
<MagnifyingGlassIcon
|
||||
class="h-5 w-5 text-zinc-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<input type="text" v-model="searchQuery"
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
class="block w-full rounded-lg border-0 bg-zinc-800/50 py-2 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-500 focus:bg-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||
placeholder="Search library..." />
|
||||
placeholder="Search library..."
|
||||
/>
|
||||
</div>
|
||||
<button @click="() => calculateGames(true)"
|
||||
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100">
|
||||
<button
|
||||
@click="() => calculateGames(true, true)"
|
||||
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100"
|
||||
>
|
||||
<ArrowPathIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
|
||||
<NuxtLink v-for="(nav, navIndex) in filteredNavigation" :key="nav.id" :class="[
|
||||
'transition-all duration-300 rounded-lg flex items-center py-2 px-3 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
|
||||
navIndex === currentNavigation
|
||||
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
|
||||
: nav.isInstalled.value
|
||||
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
|
||||
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
|
||||
]" :href="nav.route">
|
||||
<div class="flex items-center w-full gap-x-3">
|
||||
<div class="flex-none transition-transform duration-300 hover:-rotate-2">
|
||||
<img class="size-8 object-cover bg-zinc-900 rounded-lg transition-all duration-300 shadow-sm"
|
||||
:src="icons[nav.id]" alt="" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-1">
|
||||
<p class="truncate text-xs font-display leading-5 flex-1 font-semibold">
|
||||
{{ nav.label }}
|
||||
</p>
|
||||
<p class="text-xs font-medium" :class="[gameStatusTextStyle[games[nav.id].status.value.type]]">
|
||||
{{ gameStatusText[games[nav.id].status.value.type] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<Disclosure
|
||||
as="div"
|
||||
v-for="(nav, navIndex) in filteredNavigation"
|
||||
:key="nav.id"
|
||||
class="first:pt-0 last:pb-0"
|
||||
v-slot="{ open }"
|
||||
:default-open="nav.deft"
|
||||
>
|
||||
<dt>
|
||||
<DisclosureButton
|
||||
class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
|
||||
>
|
||||
<span class="text-sm font-semibold font-display">{{
|
||||
nav.name
|
||||
}}</span>
|
||||
<span class="ml-6 flex h-7 items-center">
|
||||
<PlusSmallIcon v-if="!open" class="size-6" aria-hidden="true" />
|
||||
<MinusSmallIcon v-else class="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
</dt>
|
||||
<DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
|
||||
<NuxtLink
|
||||
v-for="item in nav.items"
|
||||
:key="nav.id"
|
||||
:class="[
|
||||
'transition-all duration-300 rounded-lg flex items-center px-1 py-1.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
|
||||
currentNavigation == item.id
|
||||
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
|
||||
: item.isInstalled.value
|
||||
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
|
||||
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
|
||||
]"
|
||||
:href="item.route"
|
||||
>
|
||||
<div class="flex items-center w-full gap-x-2">
|
||||
<div
|
||||
class="flex-none transition-transform duration-300 hover:-rotate-2"
|
||||
>
|
||||
<img
|
||||
class="size-6 object-cover bg-zinc-900 rounded transition-all duration-300 shadow-sm"
|
||||
:src="icons[item.id]"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-x-2">
|
||||
<p
|
||||
class="text-sm whitespace-nowrap font-display font-semibold"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p
|
||||
class="truncate text-[10px] font-bold uppercase font-display"
|
||||
:class="[
|
||||
gameStatusTextStyle[games[item.id].status.value.type],
|
||||
]"
|
||||
>
|
||||
{{ gameStatusText[games[item.id].status.value.type] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</TransitionGroup>
|
||||
<div v-if="loading" class="h-full grow flex p-8 justify-center text-zinc-100">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="h-full grow flex p-8 justify-center text-zinc-100"
|
||||
>
|
||||
<div role="status">
|
||||
<svg aria-hidden="true" class="w-6 h-6 text-transparent animate-spin fill-zinc-600" viewBox="0 0 100 101"
|
||||
fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-6 h-6 text-transparent animate-spin fill-zinc-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor" />
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill" />
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
@ -58,9 +121,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/vue/20/solid";
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MinusSmallIcon,
|
||||
PlusSmallIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { GameStatusEnum, type Game, type GameStatus } from "~/types";
|
||||
import {
|
||||
GameStatusEnum,
|
||||
type Collection as Collection,
|
||||
type Game,
|
||||
type GameStatus,
|
||||
} from "~/types";
|
||||
import { TransitionGroup } from "vue";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
@ -70,7 +144,7 @@ const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
|
||||
[GameStatusEnum.Downloading]: "text-zinc-400",
|
||||
[GameStatusEnum.Validating]: "text-blue-300",
|
||||
[GameStatusEnum.Running]: "text-green-500",
|
||||
[GameStatusEnum.Remote]: "text-zinc-500",
|
||||
[GameStatusEnum.Remote]: "text-zinc-700",
|
||||
[GameStatusEnum.Queued]: "text-zinc-400",
|
||||
[GameStatusEnum.Updating]: "text-zinc-400",
|
||||
[GameStatusEnum.Uninstalling]: "text-zinc-100",
|
||||
@ -100,26 +174,47 @@ const games: {
|
||||
} = {};
|
||||
const icons: { [key: string]: string } = {};
|
||||
|
||||
const rawGames: Ref<Game[], Game[]> = ref([]);
|
||||
const collections: Ref<Collection[]> = ref([]);
|
||||
|
||||
async function calculateGames(clearAll = false) {
|
||||
async function calculateGames(clearAll = false, forceRefresh = false) {
|
||||
if (clearAll) {
|
||||
rawGames.value = [];
|
||||
collections.value = [];
|
||||
loading.value = true;
|
||||
}
|
||||
// If we update immediately, the navigation gets re-rendered before we
|
||||
// add all the necessary state, and it freaks tf out
|
||||
const newGames = await invoke<typeof rawGames.value>("fetch_library");
|
||||
for (const game of newGames) {
|
||||
const newGames = await invoke<Game[]>("fetch_library", {
|
||||
hardRefresh: forceRefresh,
|
||||
});
|
||||
const otherCollections = await invoke<Collection[]>("fetch_collections", {
|
||||
hardRefresh: forceRefresh,
|
||||
});
|
||||
const allGames = [
|
||||
...newGames,
|
||||
...otherCollections
|
||||
.map((e) => e.entries)
|
||||
.flat()
|
||||
.map((e) => e.game),
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
for (const game of allGames) {
|
||||
if (games[game.id]) continue;
|
||||
games[game.id] = await useGame(game.id);
|
||||
}
|
||||
for (const game of newGames) {
|
||||
for (const game of allGames) {
|
||||
if (icons[game.id]) continue;
|
||||
icons[game.id] = await useObject(game.mIconObjectId);
|
||||
}
|
||||
|
||||
const libraryCollection = {
|
||||
id: "library",
|
||||
name: "Library",
|
||||
isDefault: true,
|
||||
entries: newGames.map((e) => ({ gameId: e.id, game: e })),
|
||||
} satisfies Collection;
|
||||
|
||||
loading.value = false;
|
||||
rawGames.value = newGames;
|
||||
collections.value = [libraryCollection, ...otherCollections];
|
||||
}
|
||||
|
||||
// Wait up to 300 ms for the library to load, otherwise
|
||||
@ -128,36 +223,43 @@ await new Promise<void>((r) => {
|
||||
let hasResolved = false;
|
||||
const resolveFunc = () => {
|
||||
if (!hasResolved) r();
|
||||
hasResolved = true
|
||||
|
||||
}
|
||||
hasResolved = true;
|
||||
};
|
||||
calculateGames(true).then(resolveFunc);
|
||||
setTimeout(resolveFunc, 300);
|
||||
})
|
||||
});
|
||||
|
||||
const navigation = computed(() =>
|
||||
rawGames.value.map((game) => {
|
||||
const status = games[game.id].status;
|
||||
collections.value.map((collection) => {
|
||||
const items = collection.entries.map(({ game }) => {
|
||||
const status = games[game.id].status;
|
||||
|
||||
const isInstalled = computed(
|
||||
() =>
|
||||
status.value.type != GameStatusEnum.Remote
|
||||
);
|
||||
const isInstalled = computed(
|
||||
() => status.value.type != GameStatusEnum.Remote
|
||||
);
|
||||
|
||||
const item = {
|
||||
label: game.mName,
|
||||
route: `/library/${game.id}`,
|
||||
prefix: `/library/${game.id}`,
|
||||
isInstalled,
|
||||
id: game.id,
|
||||
const item = {
|
||||
label: game.mName,
|
||||
route: `/library/${game.id}`,
|
||||
prefix: `/library/${game.id}`,
|
||||
isInstalled,
|
||||
id: game.id,
|
||||
};
|
||||
return item;
|
||||
});
|
||||
|
||||
return {
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
deft: collection.isDefault,
|
||||
items,
|
||||
};
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
const route = useRoute();
|
||||
const currentNavigation = computed(() => {
|
||||
return navigation.value.findIndex((e) => e.route == route.path)
|
||||
return route.path.slice("/library/".length);
|
||||
});
|
||||
|
||||
const filteredNavigation = computed(() => {
|
||||
@ -165,15 +267,18 @@ const filteredNavigation = computed(() => {
|
||||
return navigation.value.map((e, i) => ({ ...e, index: i }));
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return navigation.value
|
||||
.filter((nav) => nav.label.toLowerCase().includes(query))
|
||||
.map((e, i) => ({ ...e, index: i }));
|
||||
.map((c) => ({
|
||||
...c,
|
||||
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
|
||||
}))
|
||||
.filter((e) => e.items.length > 0);
|
||||
});
|
||||
|
||||
listen("update_library", async (event) => {
|
||||
console.log("Updating library");
|
||||
let oldNavigation = navigation.value[currentNavigation.value];
|
||||
let oldNavigation = currentNavigation.value;
|
||||
await calculateGames();
|
||||
if (oldNavigation.route !== navigation.value[currentNavigation.value].route) {
|
||||
if (oldNavigation !== currentNavigation.value) {
|
||||
router.push("/library");
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
<template></template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const loading = useLoadingIndicator();
|
||||
|
||||
watch(loading.isLoading, console.log);
|
||||
</script>
|
||||
@ -32,3 +32,5 @@ listen("update_stats", (event) => {
|
||||
const stats = useStatsState();
|
||||
stats.value = event.payload as StatsState;
|
||||
});
|
||||
|
||||
export const useDownloadHistory = () => useState<Array<number>>('history', () => []);
|
||||
@ -4,18 +4,18 @@
|
||||
class="h-16 overflow-hidden relative rounded-xl flex flex-row border border-zinc-900"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 text-blue-400 font-display items-left justify-center pl-2"
|
||||
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 font-display items-left justify-center pl-2"
|
||||
>
|
||||
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}/s</span>
|
||||
<span v-if="stats.time > 0" class="text-sm"
|
||||
<span class="font-bold text-zinc-100">{{ formatKilobytes(stats.speed) }}B/s</span>
|
||||
<span v-if="stats.time > 0" class="text-xs text-zinc-400"
|
||||
>{{ formatTime(stats.time) }} left</span
|
||||
>
|
||||
</div>
|
||||
<div class="absolute inset-0 h-full flex flex-row items-end justify-end">
|
||||
<div class="absolute inset-0 h-full flex flex-row items-end justify-end space-x-[1px]">
|
||||
<div
|
||||
v-for="bar in speedHistory"
|
||||
:style="{ height: `${(bar / speedMax) * 100}%` }"
|
||||
class="w-[8px] bg-blue-600/40"
|
||||
class="w-[3px] bg-blue-600 rounded-t-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -62,9 +62,9 @@
|
||||
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
|
||||
><span class="text-zinc-300">{{
|
||||
formatKilobytes(element.current / 1000)
|
||||
}}</span>
|
||||
}}B</span>
|
||||
/
|
||||
<span class="">{{ formatKilobytes(element.max / 1000) }}</span
|
||||
<span class="">{{ formatKilobytes(element.max / 1000) }}B</span
|
||||
><ServerIcon class="size-5"
|
||||
/></span>
|
||||
</div>
|
||||
@ -91,7 +91,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { GameStatusEnum, type DownloadableMetadata, type Game, type GameStatus } from "~/types";
|
||||
import { type DownloadableMetadata, type Game, type GameStatus } from "~/types";
|
||||
|
||||
// const actionNames = {
|
||||
// [GameStatusEnum.Downloading]: "downloading",
|
||||
@ -105,12 +105,12 @@ window.addEventListener("resize", (event) => {
|
||||
|
||||
const queue = useQueueState();
|
||||
const stats = useStatsState();
|
||||
const speedHistory = useState<Array<number>>(() => []);
|
||||
const speedHistoryMax = computed(() => windowWidth.value / 8);
|
||||
const speedHistory = useDownloadHistory();
|
||||
const speedHistoryMax = computed(() => windowWidth.value / 4);
|
||||
const speedMax = computed(
|
||||
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.3
|
||||
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.1
|
||||
);
|
||||
const previousGameId = ref<string | undefined>();
|
||||
const previousGameId = useState<string | undefined>('previous_game');
|
||||
|
||||
const games: Ref<{
|
||||
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
|
||||
@ -122,14 +122,15 @@ function resetHistoryGraph() {
|
||||
}
|
||||
function checkReset(v: QueueState) {
|
||||
const currentGame = v.queue.at(0)?.meta.id;
|
||||
// If we don't have a game
|
||||
if (!currentGame) return;
|
||||
|
||||
// If we're finished
|
||||
if (!currentGame && previousGameId.value) {
|
||||
previousGameId.value = undefined;
|
||||
resetHistoryGraph();
|
||||
return;
|
||||
}
|
||||
// If we don't have a game
|
||||
if (!currentGame) return;
|
||||
// If we started a new download
|
||||
if (currentGame && !previousGameId.value) {
|
||||
previousGameId.value = currentGame;
|
||||
@ -149,9 +150,10 @@ watch(queue, (v) => {
|
||||
});
|
||||
|
||||
watch(stats, (v) => {
|
||||
if(v.speed == 0) return;
|
||||
const newLength = speedHistory.value.push(v.speed);
|
||||
if (newLength > speedHistoryMax.value) {
|
||||
speedHistory.value.splice(0, 1);
|
||||
speedHistory.value.splice(0, newLength - speedHistoryMax.value);
|
||||
}
|
||||
checkReset(queue.value);
|
||||
});
|
||||
@ -183,7 +185,7 @@ async function cancelGame(meta: DownloadableMetadata) {
|
||||
}
|
||||
|
||||
function formatKilobytes(bytes: number): string {
|
||||
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
const units = ["K", "M", "G", "T", "P"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
const scalar = 1000;
|
||||
|
||||
@ -37,6 +37,13 @@ export type Game = {
|
||||
mImageCarouselObjectIds: string[];
|
||||
};
|
||||
|
||||
export type Collection = {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
entries: Array<{ gameId: string; game: Game }>;
|
||||
};
|
||||
|
||||
export type GameVersion = {
|
||||
launchCommandTemplate: string;
|
||||
};
|
||||
|
||||
@ -14,7 +14,8 @@
|
||||
"@tauri-apps/plugin-os": "^2.3.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.0",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.1.1"
|
||||
"pino-pretty": "^13.1.1",
|
||||
"tauri": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.7.1"
|
||||
|
||||
2363
src-tauri/Cargo.lock
generated
@ -1,118 +1,14 @@
|
||||
[package]
|
||||
name = "drop-app"
|
||||
version = "0.3.3"
|
||||
description = "The client application for the open-source, self-hosted game distribution platform Drop"
|
||||
authors = ["Drop OSS"]
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
members = [
|
||||
"client",
|
||||
"database",
|
||||
"src-tauri",
|
||||
"process",
|
||||
"remote",
|
||||
"utils",
|
||||
"cloud_saves",
|
||||
"download_manager",
|
||||
"games",
|
||||
]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "drop_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
rustflags = ["-C", "target-feature=+aes,+sse2"]
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri-plugin-shell = "2.2.1"
|
||||
serde_json = "1"
|
||||
rayon = "1.10.0"
|
||||
webbrowser = "1.0.2"
|
||||
url = "2.5.2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
log = "0.4.22"
|
||||
hex = "0.4.3"
|
||||
tauri-plugin-dialog = "2"
|
||||
http = "1.1.0"
|
||||
urlencoding = "2.1.3"
|
||||
md5 = "0.7.0"
|
||||
chrono = "0.4.38"
|
||||
tauri-plugin-os = "2"
|
||||
boxcar = "0.2.7"
|
||||
umu-wrapper-lib = "0.1.0"
|
||||
tauri-plugin-autostart = "2.0.0"
|
||||
shared_child = "1.0.1"
|
||||
serde_with = "3.12.0"
|
||||
slice-deque = "0.3.0"
|
||||
throttle_my_fn = "0.2.6"
|
||||
parking_lot = "0.12.3"
|
||||
atomic-instant-full = "0.1.0"
|
||||
cacache = "13.1.0"
|
||||
http-serde = "2.1.1"
|
||||
reqwest-middleware = "0.4.0"
|
||||
reqwest-middleware-cache = "0.1.1"
|
||||
deranged = "=0.4.0"
|
||||
droplet-rs = "0.7.3"
|
||||
gethostname = "1.0.1"
|
||||
zstd = "0.13.3"
|
||||
tar = "0.4.44"
|
||||
rand = "0.9.1"
|
||||
regex = "1.11.1"
|
||||
tempfile = "3.19.1"
|
||||
schemars = "0.8.22"
|
||||
sha1 = "0.10.6"
|
||||
dirs = "6.0.0"
|
||||
whoami = "1.6.0"
|
||||
filetime = "0.2.25"
|
||||
walkdir = "2.5.0"
|
||||
known-folders = "1.2.0"
|
||||
native_model = { version = "0.6.1", features = ["rmp_serde_1_3"] }
|
||||
tauri-plugin-opener = "2.4.0"
|
||||
bitcode = "0.6.6"
|
||||
reqwest-websocket = "0.5.0"
|
||||
futures-lite = "2.6.0"
|
||||
page_size = "0.6.0"
|
||||
sysinfo = "0.36.1"
|
||||
humansize = "2.1.3"
|
||||
# tailscale = { path = "./tailscale" }
|
||||
|
||||
[dependencies.dynfmt]
|
||||
version = "0.1.5"
|
||||
features = ["curly"]
|
||||
|
||||
[dependencies.tauri]
|
||||
version = "2.7.0"
|
||||
features = ["protocol-asset", "tray-icon"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.40.0"
|
||||
features = ["rt", "tokio-macros", "signal"]
|
||||
|
||||
[dependencies.log4rs]
|
||||
version = "1.3.0"
|
||||
features = ["console_appender", "file_appender"]
|
||||
|
||||
[dependencies.rustix]
|
||||
version = "0.38.37"
|
||||
features = ["fs"]
|
||||
|
||||
[dependencies.uuid]
|
||||
version = "1.10.0"
|
||||
features = ["v4", "fast-rng", "macro-diagnostics"]
|
||||
|
||||
[dependencies.rustbreak]
|
||||
version = "2"
|
||||
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.12.22"
|
||||
default-features = false
|
||||
features = ["json", "http2", "blocking", "rustls-tls", "native-tls-alpn", "rustls-tls-native-roots"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1"
|
||||
features = ["derive", "rc"]
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = 'abort'
|
||||
resolver = "3"
|
||||
|
||||
4862
src-tauri/client/Cargo.lock
generated
Normal file
12
src-tauri/client/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bitcode = "0.6.7"
|
||||
database = { version = "0.1.0", path = "../database" }
|
||||
log = "0.4.28"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
tauri = "2.8.5"
|
||||
tauri-plugin-autostart = "2.5.0"
|
||||
12
src-tauri/client/src/app_status.rs
Normal file
@ -0,0 +1,12 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Eq, PartialEq)]
|
||||
pub enum AppStatus {
|
||||
NotConfigured,
|
||||
Offline,
|
||||
ServerError,
|
||||
SignedOut,
|
||||
SignedIn,
|
||||
SignedInNeedsReauth,
|
||||
ServerUnavailable,
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
|
||||
use database::{borrow_db_checked, borrow_db_mut_checked};
|
||||
use log::debug;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
@ -64,12 +64,3 @@ pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
#[tauri::command]
|
||||
pub fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||
toggle_autostart_logic(app, enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_autostart_enabled(app: AppHandle) -> Result<bool, tauri_plugin_autostart::Error> {
|
||||
get_autostart_enabled_logic(app)
|
||||
}
|
||||
3
src-tauri/client/src/lib.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod autostart;
|
||||
pub mod user;
|
||||
pub mod app_status;
|
||||
17
src-tauri/client/src/user.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use bitcode::{Decode, Encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Encode, Decode)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
id: String,
|
||||
username: String,
|
||||
admin: bool,
|
||||
display_name: String,
|
||||
profile_picture_object_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CompatInfo {
|
||||
umu_installed: bool,
|
||||
}
|
||||
18
src-tauri/cloud_saves/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "cloud_saves"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
database = { version = "0.1.0", path = "../database" }
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.28"
|
||||
regex = "1.11.3"
|
||||
rustix = "1.1.2"
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
tar = "0.4.44"
|
||||
tempfile = "3.23.0"
|
||||
uuid = "1.18.1"
|
||||
whoami = "1.6.1"
|
||||
zstd = "0.13.3"
|
||||
@ -1,8 +1,11 @@
|
||||
use std::{collections::HashMap, path::PathBuf, str::FromStr};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use database::platform::Platform;
|
||||
use database::{db::DATA_ROOT_DIR, GameVersion};
|
||||
use log::warn;
|
||||
|
||||
use crate::{database::db::{GameVersion, DATA_ROOT_DIR}, error::backup_error::BackupError, process::process_manager::Platform};
|
||||
use crate::error::BackupError;
|
||||
|
||||
use super::path::CommonPath;
|
||||
|
||||
@ -45,7 +48,7 @@ impl BackupManager<'_> {
|
||||
}
|
||||
|
||||
pub trait BackupHandler: Send + Sync {
|
||||
fn root_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(DATA_ROOT_DIR.lock().unwrap().join("games")) }
|
||||
fn root_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(DATA_ROOT_DIR.join("games")) }
|
||||
fn game_translate(&self, _path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::from_str(&game.game_id).unwrap()) }
|
||||
fn base_translate(&self, path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(self.root_translate(path, game)?.join(self.game_translate(path, game)?)) }
|
||||
fn home_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { let c = CommonPath::Home.get().ok_or(BackupError::NotFound); println!("{:?}", c); c }
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::process::process_manager::Platform;
|
||||
use database::platform::Platform;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Condition {
|
||||
5
src-tauri/cloud_saves/src/error.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub enum BackupError {
|
||||
InvalidSystem,
|
||||
ParseError,
|
||||
NotFound
|
||||
}
|
||||
8
src-tauri/cloud_saves/src/lib.rs
Normal file
@ -0,0 +1,8 @@
|
||||
pub mod conditions;
|
||||
pub mod metadata;
|
||||
pub mod resolver;
|
||||
pub mod placeholder;
|
||||
pub mod normalise;
|
||||
pub mod path;
|
||||
pub mod backup_manager;
|
||||
pub mod error;
|
||||
@ -1,4 +1,4 @@
|
||||
use crate::database::db::GameVersion;
|
||||
use database::GameVersion;
|
||||
|
||||
use super::conditions::{Condition};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use database::platform::Platform;
|
||||
use regex::Regex;
|
||||
use crate::process::process_manager::Platform;
|
||||
|
||||
use super::placeholder::*;
|
||||
|
||||
@ -6,17 +6,16 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::error::BackupError;
|
||||
|
||||
use super::{
|
||||
backup_manager::BackupHandler, conditions::Condition, metadata::GameFile, placeholder::*,
|
||||
};
|
||||
use database::{platform::Platform, GameVersion};
|
||||
use log::{debug, warn};
|
||||
use rustix::path::Arg;
|
||||
use tempfile::tempfile;
|
||||
|
||||
use crate::{
|
||||
database::db::GameVersion, error::backup_error::BackupError, process::process_manager::Platform,
|
||||
};
|
||||
|
||||
use super::{backup_manager::BackupManager, metadata::CloudSaveMetadata, normalise::normalize};
|
||||
|
||||
pub fn resolve(meta: &mut CloudSaveMetadata) -> File {
|
||||
0
src-tauri/cloud_saves/src/strict_path.rs
Normal file
15
src-tauri/database/Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "database"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.42"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.28"
|
||||
native_model = { version = "0.6.4", features = ["rmp_serde_1_3"], git = "https://github.com/Drop-OSS/native_model.git"}
|
||||
rustbreak = "2.0.0"
|
||||
serde = "1.0.228"
|
||||
serde_with = "3.15.0"
|
||||
url = "2.5.7"
|
||||
whoami = "1.6.1"
|
||||
47
src-tauri/database/src/db.rs
Normal file
@ -0,0 +1,47 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, LazyLock},
|
||||
};
|
||||
|
||||
use rustbreak::{DeSerError, DeSerializer};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use crate::interface::{DatabaseImpls, DatabaseInterface};
|
||||
|
||||
pub static DB: LazyLock<DatabaseInterface> = LazyLock::new(DatabaseInterface::set_up_database);
|
||||
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
static DATA_ROOT_PREFIX: &str = "drop";
|
||||
#[cfg(debug_assertions)]
|
||||
static DATA_ROOT_PREFIX: &str = "drop-debug";
|
||||
|
||||
pub static DATA_ROOT_DIR: LazyLock<Arc<PathBuf>> = LazyLock::new(|| {
|
||||
Arc::new(
|
||||
dirs::data_dir()
|
||||
.expect("Failed to get data dir")
|
||||
.join(DATA_ROOT_PREFIX),
|
||||
)
|
||||
});
|
||||
|
||||
// Custom JSON serializer to support everything we need
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DropDatabaseSerializer;
|
||||
|
||||
impl<T: native_model::Model + Serialize + DeserializeOwned> DeSerializer<T>
|
||||
for DropDatabaseSerializer
|
||||
{
|
||||
fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> {
|
||||
native_model::encode(val)
|
||||
.map_err(|e| DeSerError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
fn deserialize<R: std::io::Read>(&self, mut s: R) -> rustbreak::error::DeSerResult<T> {
|
||||
let mut buf = Vec::new();
|
||||
s.read_to_end(&mut buf)
|
||||
.map_err(|e| rustbreak::error::DeSerError::Other(e.into()))?;
|
||||
let (val, _version) = native_model::decode(buf)
|
||||
.map_err(|e| DeSerError::Internal(e.to_string()))?;
|
||||
Ok(val)
|
||||
}
|
||||
}
|
||||
@ -1,46 +1,11 @@
|
||||
use std::{
|
||||
fs::{self, create_dir_all},
|
||||
mem::ManuallyDrop,
|
||||
ops::{Deref, DerefMut},
|
||||
path::PathBuf,
|
||||
sync::{Arc, LazyLock, RwLockReadGuard, RwLockWriteGuard},
|
||||
};
|
||||
use std::{fs::{self, create_dir_all}, mem::ManuallyDrop, ops::{Deref, DerefMut}, path::PathBuf, sync::{RwLockReadGuard, RwLockWriteGuard}};
|
||||
|
||||
use chrono::Utc;
|
||||
use log::{debug, error, info, warn};
|
||||
use native_model::{Decode, Encode};
|
||||
use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use rustbreak::{PathDatabase, RustbreakError};
|
||||
use url::Url;
|
||||
|
||||
use crate::DB;
|
||||
|
||||
use super::models::data::Database;
|
||||
|
||||
pub static DATA_ROOT_DIR: LazyLock<Arc<PathBuf>> =
|
||||
LazyLock::new(|| Arc::new(dirs::data_dir().unwrap().join("drop")));
|
||||
|
||||
// Custom JSON serializer to support everything we need
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DropDatabaseSerializer;
|
||||
|
||||
impl<T: native_model::Model + Serialize + DeserializeOwned> DeSerializer<T>
|
||||
for DropDatabaseSerializer
|
||||
{
|
||||
fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> {
|
||||
native_model::rmp_serde_1_3::RmpSerde::encode(val)
|
||||
.map_err(|e| DeSerError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
fn deserialize<R: std::io::Read>(&self, mut s: R) -> rustbreak::error::DeSerResult<T> {
|
||||
let mut buf = Vec::new();
|
||||
s.read_to_end(&mut buf)
|
||||
.map_err(|e| rustbreak::error::DeSerError::Other(e.into()))?;
|
||||
let val = native_model::rmp_serde_1_3::RmpSerde::decode(buf)
|
||||
.map_err(|e| DeSerError::Internal(e.to_string()))?;
|
||||
Ok(val)
|
||||
}
|
||||
}
|
||||
use crate::{db::{DropDatabaseSerializer, DATA_ROOT_DIR, DB}, models::data::Database};
|
||||
|
||||
pub type DatabaseInterface =
|
||||
rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer>;
|
||||
@ -59,13 +24,49 @@ impl DatabaseImpls for DatabaseInterface {
|
||||
let pfx_dir = DATA_ROOT_DIR.join("pfx");
|
||||
|
||||
debug!("creating data directory at {DATA_ROOT_DIR:?}");
|
||||
create_dir_all(DATA_ROOT_DIR.as_path()).unwrap();
|
||||
create_dir_all(&games_base_dir).unwrap();
|
||||
create_dir_all(&logs_root_dir).unwrap();
|
||||
create_dir_all(&cache_dir).unwrap();
|
||||
create_dir_all(&pfx_dir).unwrap();
|
||||
create_dir_all(DATA_ROOT_DIR.as_path()).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create directory {} with error {}",
|
||||
DATA_ROOT_DIR.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
create_dir_all(&games_base_dir).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create directory {} with error {}",
|
||||
games_base_dir.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
create_dir_all(&logs_root_dir).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create directory {} with error {}",
|
||||
logs_root_dir.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
create_dir_all(&cache_dir).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create directory {} with error {}",
|
||||
cache_dir.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
create_dir_all(&pfx_dir).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create directory {} with error {}",
|
||||
pfx_dir.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
|
||||
let exists = fs::exists(db_path.clone()).unwrap();
|
||||
let exists = fs::exists(db_path.clone()).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to find if {} exists with error {}",
|
||||
db_path.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
|
||||
if exists {
|
||||
match PathDatabase::load_from_path(db_path.clone()) {
|
||||
@ -74,21 +75,19 @@ impl DatabaseImpls for DatabaseInterface {
|
||||
}
|
||||
} else {
|
||||
let default = Database::new(games_base_dir, None, cache_dir);
|
||||
debug!(
|
||||
"Creating database at path {}",
|
||||
db_path.as_os_str().to_str().unwrap()
|
||||
);
|
||||
debug!("Creating database at path {}", db_path.display());
|
||||
PathDatabase::create_at_path(db_path, default).expect("Database could not be created")
|
||||
}
|
||||
}
|
||||
|
||||
fn database_is_set_up(&self) -> bool {
|
||||
!self.borrow_data().unwrap().base_url.is_empty()
|
||||
!borrow_db_checked().base_url.is_empty()
|
||||
}
|
||||
|
||||
fn fetch_base_url(&self) -> Url {
|
||||
let handle = self.borrow_data().unwrap();
|
||||
Url::parse(&handle.base_url).unwrap()
|
||||
let handle = borrow_db_checked();
|
||||
Url::parse(&handle.base_url)
|
||||
.unwrap_or_else(|_| panic!("Failed to parse base url {}", handle.base_url))
|
||||
}
|
||||
}
|
||||
|
||||
@ -107,13 +106,16 @@ fn handle_invalid_database(
|
||||
base
|
||||
};
|
||||
info!("old database stored at: {}", new_path.to_string_lossy());
|
||||
fs::rename(&db_path, &new_path).unwrap();
|
||||
fs::rename(&db_path, &new_path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Could not rename database {} to {} with error {}",
|
||||
db_path.display(),
|
||||
new_path.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
|
||||
let db = Database::new(
|
||||
games_base_dir.into_os_string().into_string().unwrap(),
|
||||
Some(new_path),
|
||||
cache_dir,
|
||||
);
|
||||
let db = Database::new(games_base_dir, Some(new_path), cache_dir);
|
||||
|
||||
PathDatabase::create_at_path(db_path, db).expect("Database could not be created")
|
||||
}
|
||||
21
src-tauri/database/src/lib.rs
Normal file
@ -0,0 +1,21 @@
|
||||
#![feature(nonpoison_rwlock)]
|
||||
|
||||
pub mod db;
|
||||
pub mod debug;
|
||||
pub mod models;
|
||||
pub mod platform;
|
||||
pub mod interface;
|
||||
|
||||
pub use models::data::{
|
||||
ApplicationTransientStatus,
|
||||
Database,
|
||||
DatabaseApplications,
|
||||
DatabaseAuth,
|
||||
DownloadType,
|
||||
DownloadableMetadata,
|
||||
GameDownloadStatus,
|
||||
GameVersion,
|
||||
Settings
|
||||
};
|
||||
pub use db::DB;
|
||||
pub use interface::{borrow_db_checked, borrow_db_mut_checked};
|
||||
@ -1,17 +1,12 @@
|
||||
/**
|
||||
* NEXT BREAKING CHANGE
|
||||
*
|
||||
* UPDATE DATABASE TO USE RPMSERDENAMED
|
||||
*
|
||||
* WE CAN'T DELETE ANY FIELDS
|
||||
*/
|
||||
pub mod data {
|
||||
use std::path::PathBuf;
|
||||
use std::{hash::Hash, path::PathBuf};
|
||||
|
||||
|
||||
use native_model::native_model;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// NOTE: Within each version, you should NEVER use these types.
|
||||
// Declare it using the actual version that it is from, i.e. v1::Settings rather than just Settings from here
|
||||
|
||||
pub type GameVersion = v1::GameVersion;
|
||||
pub type Database = v3::Database;
|
||||
pub type Settings = v1::Settings;
|
||||
@ -19,25 +14,41 @@ pub mod data {
|
||||
|
||||
pub type GameDownloadStatus = v2::GameDownloadStatus;
|
||||
pub type ApplicationTransientStatus = v1::ApplicationTransientStatus;
|
||||
/**
|
||||
* Need to be universally accessible by the ID, and the version is just a couple sprinkles on top
|
||||
*/
|
||||
pub type DownloadableMetadata = v1::DownloadableMetadata;
|
||||
pub type DownloadType = v1::DownloadType;
|
||||
pub type DatabaseApplications = v2::DatabaseApplications;
|
||||
pub type DatabaseCompatInfo = v2::DatabaseCompatInfo;
|
||||
// pub type DatabaseCompatInfo = v2::DatabaseCompatInfo;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub mod v1 {
|
||||
use crate::process::process_manager::Platform;
|
||||
impl PartialEq for DownloadableMetadata {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id && self.download_type == other.download_type
|
||||
}
|
||||
}
|
||||
impl Hash for DownloadableMetadata {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
self.download_type.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
mod v1 {
|
||||
use serde_with::serde_as;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use crate::platform::Platform;
|
||||
|
||||
use super::{Deserialize, Serialize, native_model};
|
||||
|
||||
fn default_template() -> String {
|
||||
"{}".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[native_model(id = 2, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
|
||||
pub struct GameVersion {
|
||||
@ -116,6 +127,7 @@ pub mod data {
|
||||
// Stuff that shouldn't be synced to disk
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub enum ApplicationTransientStatus {
|
||||
Queued { version_name: String },
|
||||
Downloading { version_name: String },
|
||||
Uninstalling {},
|
||||
Updating { version_name: String },
|
||||
@ -144,7 +156,7 @@ pub mod data {
|
||||
}
|
||||
|
||||
#[native_model(id = 7, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadableMetadata {
|
||||
pub id: String,
|
||||
@ -174,22 +186,21 @@ pub mod data {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod v2 {
|
||||
mod v2 {
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use serde_with::serde_as;
|
||||
|
||||
use super::{
|
||||
ApplicationTransientStatus, DatabaseAuth, Deserialize, DownloadableMetadata,
|
||||
GameVersion, Serialize, Settings, native_model, v1,
|
||||
Deserialize, Serialize, native_model, v1,
|
||||
};
|
||||
|
||||
#[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
|
||||
#[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::Database)]
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Database {
|
||||
#[serde(default)]
|
||||
pub settings: Settings,
|
||||
pub auth: Option<DatabaseAuth>,
|
||||
pub settings: v1::Settings,
|
||||
pub auth: Option<v1::DatabaseAuth>,
|
||||
pub base_url: String,
|
||||
pub applications: v1::DatabaseApplications,
|
||||
#[serde(skip)]
|
||||
@ -198,7 +209,7 @@ pub mod data {
|
||||
pub compat_info: Option<DatabaseCompatInfo>,
|
||||
}
|
||||
|
||||
#[native_model(id = 8, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
|
||||
#[native_model(id = 9, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
|
||||
pub struct DatabaseCompatInfo {
|
||||
@ -221,7 +232,7 @@ pub mod data {
|
||||
// Strings are version names for a particular game
|
||||
#[derive(Serialize, Clone, Deserialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
#[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
|
||||
#[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::GameDownloadStatus)]
|
||||
pub enum GameDownloadStatus {
|
||||
Remote {},
|
||||
SetupRequired {
|
||||
@ -261,17 +272,17 @@ pub mod data {
|
||||
#[serde_as]
|
||||
#[derive(Serialize, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
|
||||
#[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from=v1::DatabaseApplications)]
|
||||
pub struct DatabaseApplications {
|
||||
pub install_dirs: Vec<PathBuf>,
|
||||
// Guaranteed to exist if the game also exists in the app state map
|
||||
pub game_statuses: HashMap<String, GameDownloadStatus>,
|
||||
|
||||
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
|
||||
pub installed_game_version: HashMap<String, DownloadableMetadata>,
|
||||
pub game_versions: HashMap<String, HashMap<String, v1::GameVersion>>,
|
||||
pub installed_game_version: HashMap<String, v1::DownloadableMetadata>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>,
|
||||
pub transient_statuses: HashMap<v1::DownloadableMetadata, v1::ApplicationTransientStatus>,
|
||||
}
|
||||
impl From<v1::DatabaseApplications> for DatabaseApplications {
|
||||
fn from(value: v1::DatabaseApplications) -> Self {
|
||||
@ -293,21 +304,21 @@ pub mod data {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{
|
||||
DatabaseApplications, DatabaseAuth, DatabaseCompatInfo, Deserialize, Serialize,
|
||||
Settings, native_model, v2,
|
||||
Deserialize, Serialize,
|
||||
native_model, v2, v1,
|
||||
};
|
||||
#[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde)]
|
||||
#[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde, from = v2::Database)]
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Database {
|
||||
#[serde(default)]
|
||||
pub settings: Settings,
|
||||
pub auth: Option<DatabaseAuth>,
|
||||
pub settings: v1::Settings,
|
||||
pub auth: Option<v1::DatabaseAuth>,
|
||||
pub base_url: String,
|
||||
pub applications: DatabaseApplications,
|
||||
pub applications: v2::DatabaseApplications,
|
||||
#[serde(skip)]
|
||||
pub prev_database: Option<PathBuf>,
|
||||
pub cache_dir: PathBuf,
|
||||
pub compat_info: Option<DatabaseCompatInfo>,
|
||||
pub compat_info: Option<v2::DatabaseCompatInfo>,
|
||||
}
|
||||
|
||||
impl From<v2::Database> for Database {
|
||||
@ -347,5 +358,6 @@ pub mod data {
|
||||
compat_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
47
src-tauri/database/src/platform.rs
Normal file
@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
pub enum Platform {
|
||||
Windows,
|
||||
Linux,
|
||||
MacOs,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const HOST: Platform = Self::Windows;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub const HOST: Platform = Self::MacOs;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const HOST: Platform = Self::Linux;
|
||||
|
||||
pub fn is_case_sensitive(&self) -> bool {
|
||||
match self {
|
||||
Self::Windows | Self::MacOs => false,
|
||||
Self::Linux => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Platform {
|
||||
fn from(value: &str) -> Self {
|
||||
match value.to_lowercase().trim() {
|
||||
"windows" => Self::Windows,
|
||||
"linux" => Self::Linux,
|
||||
"mac" | "macos" => Self::MacOs,
|
||||
_ => unimplemented!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<whoami::Platform> for Platform {
|
||||
fn from(value: whoami::Platform) -> Self {
|
||||
match value {
|
||||
whoami::Platform::Windows => Platform::Windows,
|
||||
whoami::Platform::Linux => Platform::Linux,
|
||||
whoami::Platform::MacOS => Platform::MacOs,
|
||||
platform => unimplemented!("Playform {} is not supported", platform),
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src-tauri/download_manager/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "download_manager"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
atomic-instant-full = "0.1.0"
|
||||
database = { version = "0.1.0", path = "../database" }
|
||||
humansize = "2.1.3"
|
||||
log = "0.4.28"
|
||||
parking_lot = "0.12.5"
|
||||
remote = { version = "0.1.0", path = "../remote" }
|
||||
serde = "1.0.228"
|
||||
serde_with = "3.15.0"
|
||||
tauri = "2.8.5"
|
||||
throttle_my_fn = "0.2.6"
|
||||
utils = { version = "0.1.0", path = "../utils" }
|
||||
@ -7,14 +7,13 @@ use std::{
|
||||
thread::{JoinHandle, spawn},
|
||||
};
|
||||
|
||||
use database::DownloadableMetadata;
|
||||
use log::{debug, error, info, warn};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use utils::{app_emit, lock, send};
|
||||
|
||||
use crate::{
|
||||
database::models::data::DownloadableMetadata,
|
||||
error::application_download_error::ApplicationDownloadError,
|
||||
games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent},
|
||||
};
|
||||
|
||||
use crate::{download_manager_frontend::DownloadStatus, error::ApplicationDownloadError, frontend_updates::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent}};
|
||||
|
||||
use super::{
|
||||
download_manager_frontend::{DownloadManager, DownloadManagerSignal, DownloadManagerStatus},
|
||||
@ -75,7 +74,6 @@ pub struct DownloadManagerBuilder {
|
||||
status: Arc<Mutex<DownloadManagerStatus>>,
|
||||
app_handle: AppHandle,
|
||||
|
||||
current_download_agent: Option<DownloadAgent>, // Should be the only download agent in the map with the "Go" flag
|
||||
current_download_thread: Mutex<Option<JoinHandle<()>>>,
|
||||
active_control_flag: Option<DownloadThreadControl>,
|
||||
}
|
||||
@ -95,7 +93,6 @@ impl DownloadManagerBuilder {
|
||||
progress: active_progress.clone(),
|
||||
app_handle,
|
||||
|
||||
current_download_agent: None,
|
||||
current_download_thread: Mutex::new(None),
|
||||
active_control_flag: None,
|
||||
};
|
||||
@ -106,7 +103,7 @@ impl DownloadManagerBuilder {
|
||||
}
|
||||
|
||||
fn set_status(&self, status: DownloadManagerStatus) {
|
||||
*self.status.lock().unwrap() = status;
|
||||
*lock!(self.status) = status;
|
||||
}
|
||||
|
||||
fn remove_and_cleanup_front_download(&mut self, meta: &DownloadableMetadata) -> DownloadAgent {
|
||||
@ -120,10 +117,9 @@ impl DownloadManagerBuilder {
|
||||
// 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;
|
||||
*lock!(self.progress) = None;
|
||||
|
||||
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
|
||||
let mut download_thread_lock = lock!(self.current_download_thread);
|
||||
|
||||
if let Some(unfinished_thread) = download_thread_lock.take()
|
||||
&& !unfinished_thread.is_finished()
|
||||
@ -139,7 +135,7 @@ impl DownloadManagerBuilder {
|
||||
current_flag.set(DownloadThreadControlFlag::Stop);
|
||||
}
|
||||
|
||||
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
|
||||
let mut download_thread_lock = lock!(self.current_download_thread);
|
||||
if let Some(current_download_thread) = download_thread_lock.take() {
|
||||
return current_download_thread.join().is_ok();
|
||||
};
|
||||
@ -197,13 +193,11 @@ impl DownloadManagerBuilder {
|
||||
return;
|
||||
}
|
||||
|
||||
download_agent.on_initialised(&self.app_handle);
|
||||
download_agent.on_queued(&self.app_handle);
|
||||
self.download_queue.append(meta.clone());
|
||||
self.download_agent_registry.insert(meta, download_agent);
|
||||
|
||||
self.sender
|
||||
.send(DownloadManagerSignal::UpdateUIQueue)
|
||||
.unwrap();
|
||||
send!(self.sender, DownloadManagerSignal::UpdateUIQueue);
|
||||
}
|
||||
|
||||
fn manage_go_signal(&mut self) {
|
||||
@ -216,19 +210,13 @@ impl DownloadManagerBuilder {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.current_download_agent.is_some()
|
||||
&& self.download_queue.read().front().unwrap()
|
||||
== &self.current_download_agent.as_ref().unwrap().metadata()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
debug!("current download queue: {:?}", self.download_queue.read());
|
||||
|
||||
// Should always be Some if the above two statements keep going
|
||||
let agent_data = self.download_queue.read().front().unwrap().clone();
|
||||
|
||||
info!("starting download for {agent_data:?}");
|
||||
let agent_data = if let Some(agent_data) = self.download_queue.read().front() {
|
||||
agent_data.clone()
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
let download_agent = self
|
||||
.download_agent_registry
|
||||
@ -236,12 +224,26 @@ impl DownloadManagerBuilder {
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
let status = download_agent.status();
|
||||
|
||||
// This download is already going
|
||||
if status != DownloadStatus::Queued {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure all others are marked as queued
|
||||
for agent in self.download_agent_registry.values() {
|
||||
if agent.metadata() != agent_data && agent.status() != DownloadStatus::Queued {
|
||||
agent.on_queued(&self.app_handle);
|
||||
}
|
||||
}
|
||||
|
||||
info!("starting download for {agent_data:?}");
|
||||
self.active_control_flag = Some(download_agent.control_flag());
|
||||
self.current_download_agent = Some(download_agent.clone());
|
||||
|
||||
let sender = self.sender.clone();
|
||||
|
||||
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
|
||||
let mut download_thread_lock = lock!(self.current_download_thread);
|
||||
let app_handle = self.app_handle.clone();
|
||||
|
||||
*download_thread_lock = Some(spawn(move || {
|
||||
@ -252,7 +254,7 @@ impl DownloadManagerBuilder {
|
||||
Err(e) => {
|
||||
error!("download {:?} has error {}", download_agent.metadata(), &e);
|
||||
download_agent.on_error(&app_handle, &e);
|
||||
sender.send(DownloadManagerSignal::Error(e)).unwrap();
|
||||
send!(sender, DownloadManagerSignal::Error(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@ -276,7 +278,7 @@ impl DownloadManagerBuilder {
|
||||
&e
|
||||
);
|
||||
download_agent.on_error(&app_handle, &e);
|
||||
sender.send(DownloadManagerSignal::Error(e)).unwrap();
|
||||
send!(sender, DownloadManagerSignal::Error(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@ -287,10 +289,8 @@ impl DownloadManagerBuilder {
|
||||
|
||||
if validate_result {
|
||||
download_agent.on_complete(&app_handle);
|
||||
sender
|
||||
.send(DownloadManagerSignal::Completed(download_agent.metadata()))
|
||||
.unwrap();
|
||||
sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap();
|
||||
send!(sender, DownloadManagerSignal::Completed(download_agent.metadata()));
|
||||
send!(sender, DownloadManagerSignal::UpdateUIQueue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -310,22 +310,24 @@ impl DownloadManagerBuilder {
|
||||
}
|
||||
fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
|
||||
debug!("got signal Completed");
|
||||
if let Some(interface) = &self.current_download_agent
|
||||
&& interface.metadata() == meta
|
||||
if let Some(interface) = self.download_queue.read().front()
|
||||
&& interface == &meta
|
||||
{
|
||||
self.remove_and_cleanup_front_download(&meta);
|
||||
}
|
||||
|
||||
self.push_ui_queue_update();
|
||||
self.sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
send!(self.sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
|
||||
debug!("got signal Error");
|
||||
if let Some(current_agent) = self.current_download_agent.clone() {
|
||||
if let Some(metadata) = self.download_queue.read().front()
|
||||
&& let Some(current_agent) = self.download_agent_registry.get(metadata)
|
||||
{
|
||||
current_agent.on_error(&self.app_handle, &error);
|
||||
|
||||
self.stop_and_wait_current_download();
|
||||
self.remove_and_cleanup_front_download(¤t_agent.metadata());
|
||||
self.remove_and_cleanup_front_download(metadata);
|
||||
}
|
||||
self.push_ui_queue_update();
|
||||
self.set_status(DownloadManagerStatus::Error);
|
||||
@ -333,36 +335,27 @@ impl DownloadManagerBuilder {
|
||||
fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) {
|
||||
debug!("got signal Cancel");
|
||||
|
||||
if let Some(current_download) = &self.current_download_agent {
|
||||
if ¤t_download.metadata() == meta {
|
||||
self.set_status(DownloadManagerStatus::Paused);
|
||||
current_download.on_cancelled(&self.app_handle);
|
||||
self.stop_and_wait_current_download();
|
||||
// If the current download is the one we're tryna cancel
|
||||
if let Some(current_metadata) = self.download_queue.read().front()
|
||||
&& current_metadata == meta
|
||||
&& let Some(current_download) = self.download_agent_registry.get(current_metadata)
|
||||
{
|
||||
self.set_status(DownloadManagerStatus::Paused);
|
||||
current_download.on_cancelled(&self.app_handle);
|
||||
self.stop_and_wait_current_download();
|
||||
|
||||
self.download_queue.pop_front();
|
||||
self.download_queue.pop_front();
|
||||
|
||||
self.cleanup_current_download();
|
||||
debug!("current download queue: {:?}", self.download_queue.read());
|
||||
}
|
||||
// TODO: Collapse these two into a single if statement somehow
|
||||
else if let Some(download_agent) = self.download_agent_registry.get(meta) {
|
||||
let index = self.download_queue.get_by_meta(meta);
|
||||
if let Some(index) = index {
|
||||
download_agent.on_cancelled(&self.app_handle);
|
||||
let _ = self.download_queue.edit().remove(index).unwrap();
|
||||
let removed = self.download_agent_registry.remove(meta);
|
||||
debug!(
|
||||
"removed {:?} from queue {:?}",
|
||||
removed.map(|x| x.metadata()),
|
||||
self.download_queue.read()
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Some(download_agent) = self.download_agent_registry.get(meta) {
|
||||
self.cleanup_current_download();
|
||||
self.download_agent_registry.remove(meta);
|
||||
debug!("current download queue: {:?}", self.download_queue.read());
|
||||
}
|
||||
// else just cancel it
|
||||
else if let Some(download_agent) = self.download_agent_registry.get(meta) {
|
||||
let index = self.download_queue.get_by_meta(meta);
|
||||
if let Some(index) = index {
|
||||
download_agent.on_cancelled(&self.app_handle);
|
||||
let _ = self.download_queue.edit().remove(index).unwrap();
|
||||
let _ = self.download_queue.edit().remove(index);
|
||||
let removed = self.download_agent_registry.remove(meta);
|
||||
debug!(
|
||||
"removed {:?} from queue {:?}",
|
||||
@ -371,12 +364,13 @@ impl DownloadManagerBuilder {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
self.push_ui_queue_update();
|
||||
}
|
||||
fn push_ui_stats_update(&self, kbs: usize, time: usize) {
|
||||
let event_data = StatsUpdateEvent { speed: kbs, time };
|
||||
|
||||
self.app_handle.emit("update_stats", event_data).unwrap();
|
||||
app_emit!(self.app_handle, "update_stats", event_data);
|
||||
}
|
||||
fn push_ui_queue_update(&self) {
|
||||
let queue = &self.download_queue.read();
|
||||
@ -395,6 +389,6 @@ impl DownloadManagerBuilder {
|
||||
.collect();
|
||||
|
||||
let event_data = QueueUpdateEvent { queue: queue_objs };
|
||||
self.app_handle.emit("update_queue", event_data).unwrap();
|
||||
app_emit!(self.app_handle, "update_queue", event_data);
|
||||
}
|
||||
}
|
||||
@ -3,19 +3,19 @@ use std::{
|
||||
collections::VecDeque,
|
||||
fmt::Debug,
|
||||
sync::{
|
||||
mpsc::{SendError, Sender},
|
||||
Mutex, MutexGuard,
|
||||
mpsc::{SendError, Sender},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
};
|
||||
|
||||
use database::DownloadableMetadata;
|
||||
use log::{debug, info};
|
||||
use serde::Serialize;
|
||||
use utils::{lock, send};
|
||||
|
||||
use crate::{
|
||||
database::models::data::DownloadableMetadata,
|
||||
error::application_download_error::ApplicationDownloadError,
|
||||
};
|
||||
|
||||
use crate::error::ApplicationDownloadError;
|
||||
|
||||
use super::{
|
||||
download_manager_builder::{CurrentProgressObject, DownloadAgent},
|
||||
@ -62,7 +62,7 @@ impl Serialize for DownloadManagerStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[derive(Serialize, Clone, Debug, PartialEq)]
|
||||
pub enum DownloadStatus {
|
||||
Queued,
|
||||
Downloading,
|
||||
@ -119,22 +119,18 @@ impl DownloadManager {
|
||||
self.download_queue.read()
|
||||
}
|
||||
pub fn get_current_download_progress(&self) -> Option<f64> {
|
||||
let progress_object = (*self.progress.lock().unwrap()).clone()?;
|
||||
let progress_object = (*lock!(self.progress)).clone()?;
|
||||
Some(progress_object.get_progress())
|
||||
}
|
||||
pub fn rearrange_string(&self, meta: &DownloadableMetadata, new_index: usize) {
|
||||
let mut queue = self.edit();
|
||||
let current_index = get_index_from_id(&mut queue, meta).unwrap();
|
||||
let to_move = queue.remove(current_index).unwrap();
|
||||
let current_index = get_index_from_id(&mut queue, meta).expect("Failed to get meta index from id");
|
||||
let to_move = queue.remove(current_index).expect("Failed to remove meta at index from queue");
|
||||
queue.insert(new_index, to_move);
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::UpdateUIQueue)
|
||||
.unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::UpdateUIQueue);
|
||||
}
|
||||
pub fn cancel(&self, meta: DownloadableMetadata) {
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Cancel(meta))
|
||||
.unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::Cancel(meta));
|
||||
}
|
||||
pub fn rearrange(&self, current_index: usize, new_index: usize) {
|
||||
if current_index == new_index {
|
||||
@ -143,39 +139,31 @@ impl DownloadManager {
|
||||
|
||||
let needs_pause = current_index == 0 || new_index == 0;
|
||||
if needs_pause {
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Stop)
|
||||
.unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::Stop);
|
||||
}
|
||||
|
||||
debug!("moving download at index {current_index} to index {new_index}");
|
||||
|
||||
let mut queue = self.edit();
|
||||
let to_move = queue.remove(current_index).unwrap();
|
||||
let to_move = queue.remove(current_index).expect("Failed to get");
|
||||
queue.insert(new_index, to_move);
|
||||
drop(queue);
|
||||
|
||||
if needs_pause {
|
||||
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::UpdateUIQueue)
|
||||
.unwrap();
|
||||
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::UpdateUIQueue);
|
||||
send!(self.command_sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
pub fn pause_downloads(&self) {
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Stop)
|
||||
.unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::Stop);
|
||||
}
|
||||
pub fn resume_downloads(&self) {
|
||||
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
|
||||
send!(self.command_sender, DownloadManagerSignal::Go);
|
||||
}
|
||||
pub fn ensure_terminated(&self) -> Result<Result<(), ()>, Box<dyn Any + Send>> {
|
||||
self.command_sender
|
||||
.send(DownloadManagerSignal::Finish)
|
||||
.unwrap();
|
||||
let terminator = self.terminator.lock().unwrap().take();
|
||||
send!(self.command_sender, DownloadManagerSignal::Finish);
|
||||
let terminator = lock!(self.terminator).take();
|
||||
terminator.unwrap().join()
|
||||
}
|
||||
pub fn get_sender(&self) -> Sender<DownloadManagerSignal> {
|
||||
@ -1,17 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use database::DownloadableMetadata;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
database::models::data::DownloadableMetadata,
|
||||
error::application_download_error::ApplicationDownloadError,
|
||||
};
|
||||
|
||||
use crate::error::ApplicationDownloadError;
|
||||
|
||||
use super::{
|
||||
download_manager_frontend::DownloadStatus,
|
||||
util::{download_thread_control_flag::DownloadThreadControl, progress_object::ProgressObject},
|
||||
};
|
||||
|
||||
/**
|
||||
* Downloadables are responsible for managing their specific object's download state
|
||||
* e.g, the GameDownloadAgent is responsible for pushing game updates
|
||||
*
|
||||
* But the download manager manages the queue state
|
||||
*/
|
||||
pub trait Downloadable: Send + Sync {
|
||||
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError>;
|
||||
fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError>;
|
||||
@ -20,7 +25,7 @@ pub trait Downloadable: Send + Sync {
|
||||
fn control_flag(&self) -> DownloadThreadControl;
|
||||
fn status(&self) -> DownloadStatus;
|
||||
fn metadata(&self) -> DownloadableMetadata;
|
||||
fn on_initialised(&self, app_handle: &AppHandle);
|
||||
fn on_queued(&self, app_handle: &AppHandle);
|
||||
fn on_error(&self, app_handle: &AppHandle, error: &ApplicationDownloadError);
|
||||
fn on_complete(&self, app_handle: &AppHandle);
|
||||
fn on_cancelled(&self, app_handle: &AppHandle);
|
||||
@ -1,12 +1,32 @@
|
||||
use std::{
|
||||
fmt::{Display, Formatter},
|
||||
io, sync::Arc,
|
||||
};
|
||||
|
||||
use serde_with::SerializeDisplay;
|
||||
use std::{fmt::{Display, Formatter}, io, sync::{mpsc::SendError, Arc}};
|
||||
use humansize::{format_size, BINARY};
|
||||
|
||||
use super::remote_access_error::RemoteAccessError;
|
||||
use remote::error::RemoteAccessError;
|
||||
use serde_with::SerializeDisplay;
|
||||
|
||||
#[derive(SerializeDisplay)]
|
||||
pub enum DownloadManagerError<T> {
|
||||
IOError(io::Error),
|
||||
SignalError(SendError<T>),
|
||||
}
|
||||
impl<T> Display for DownloadManagerError<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DownloadManagerError::IOError(error) => write!(f, "{error}"),
|
||||
DownloadManagerError::SignalError(send_error) => write!(f, "{send_error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T> From<SendError<T>> for DownloadManagerError<T> {
|
||||
fn from(value: SendError<T>) -> Self {
|
||||
DownloadManagerError::SignalError(value)
|
||||
}
|
||||
}
|
||||
impl<T> From<io::Error> for DownloadManagerError<T> {
|
||||
fn from(value: io::Error) -> Self {
|
||||
DownloadManagerError::IOError(value)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Rename / separate from downloads
|
||||
#[derive(Debug, SerializeDisplay)]
|
||||
@ -18,7 +38,7 @@ pub enum ApplicationDownloadError {
|
||||
Checksum,
|
||||
Lock,
|
||||
IoError(Arc<io::Error>),
|
||||
DownloadError,
|
||||
DownloadError(RemoteAccessError),
|
||||
}
|
||||
|
||||
impl Display for ApplicationDownloadError {
|
||||
@ -40,10 +60,16 @@ impl Display for ApplicationDownloadError {
|
||||
write!(f, "checksum failed to validate for download")
|
||||
}
|
||||
ApplicationDownloadError::IoError(error) => write!(f, "io error: {error}"),
|
||||
ApplicationDownloadError::DownloadError => write!(
|
||||
ApplicationDownloadError::DownloadError(error) => write!(
|
||||
f,
|
||||
"Download failed. See Download Manager status for specific error"
|
||||
"Download failed with error {error:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for ApplicationDownloadError {
|
||||
fn from(value: io::Error) -> Self {
|
||||
ApplicationDownloadError::IoError(Arc::new(value))
|
||||
}
|
||||
}
|
||||
24
src-tauri/download_manager/src/frontend_updates.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use database::DownloadableMetadata;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::download_manager_frontend::DownloadStatus;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct QueueUpdateEventQueueData {
|
||||
pub meta: DownloadableMetadata,
|
||||
pub status: DownloadStatus,
|
||||
pub progress: f64,
|
||||
pub current: usize,
|
||||
pub max: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct QueueUpdateEvent {
|
||||
pub queue: Vec<QueueUpdateEventQueueData>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct StatsUpdateEvent {
|
||||
pub speed: usize,
|
||||
pub time: usize,
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
pub mod commands;
|
||||
#![feature(duration_millis_float)]
|
||||
|
||||
pub mod download_manager_builder;
|
||||
pub mod download_manager_frontend;
|
||||
pub mod downloadable;
|
||||
pub mod util;
|
||||
pub mod error;
|
||||
pub mod frontend_updates;
|
||||
@ -9,8 +9,9 @@ use std::{
|
||||
|
||||
use atomic_instant_full::AtomicInstant;
|
||||
use throttle_my_fn::throttle;
|
||||
use utils::{lock, send};
|
||||
|
||||
use crate::download_manager::download_manager_frontend::DownloadManagerSignal;
|
||||
use crate::download_manager_frontend::DownloadManagerSignal;
|
||||
|
||||
use super::rolling_progress_updates::RollingProgressWindow;
|
||||
|
||||
@ -23,7 +24,7 @@ pub struct ProgressObject {
|
||||
//last_update: Arc<RwLock<Instant>>,
|
||||
last_update_time: Arc<AtomicInstant>,
|
||||
bytes_last_update: Arc<AtomicUsize>,
|
||||
rolling: RollingProgressWindow<1>,
|
||||
rolling: RollingProgressWindow<1000>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -74,12 +75,10 @@ impl ProgressObject {
|
||||
}
|
||||
|
||||
pub fn set_time_now(&self) {
|
||||
*self.start.lock().unwrap() = Instant::now();
|
||||
*lock!(self.start) = Instant::now();
|
||||
}
|
||||
pub fn sum(&self) -> usize {
|
||||
self.progress_instances
|
||||
.lock()
|
||||
.unwrap()
|
||||
lock!(self.progress_instances)
|
||||
.iter()
|
||||
.map(|instance| instance.load(Ordering::Acquire))
|
||||
.sum()
|
||||
@ -88,27 +87,25 @@ impl ProgressObject {
|
||||
self.set_time_now();
|
||||
self.bytes_last_update.store(0, Ordering::Release);
|
||||
self.rolling.reset();
|
||||
self.progress_instances
|
||||
.lock()
|
||||
.unwrap()
|
||||
lock!(self.progress_instances)
|
||||
.iter()
|
||||
.for_each(|x| x.store(0, Ordering::SeqCst));
|
||||
}
|
||||
pub fn get_max(&self) -> usize {
|
||||
*self.max.lock().unwrap()
|
||||
*lock!(self.max)
|
||||
}
|
||||
pub fn set_max(&self, new_max: usize) {
|
||||
*self.max.lock().unwrap() = new_max;
|
||||
*lock!(self.max) = new_max;
|
||||
}
|
||||
pub fn set_size(&self, length: usize) {
|
||||
*self.progress_instances.lock().unwrap() =
|
||||
*lock!(self.progress_instances) =
|
||||
(0..length).map(|_| Arc::new(AtomicUsize::new(0))).collect();
|
||||
}
|
||||
pub fn get_progress(&self) -> f64 {
|
||||
self.sum() as f64 / self.get_max() as f64
|
||||
}
|
||||
pub fn get(&self, index: usize) -> Arc<AtomicUsize> {
|
||||
self.progress_instances.lock().unwrap()[index].clone()
|
||||
lock!(self.progress_instances)[index].clone()
|
||||
}
|
||||
fn update_window(&self, kilobytes_per_second: usize) {
|
||||
self.rolling.update(kilobytes_per_second);
|
||||
@ -120,7 +117,7 @@ pub fn calculate_update(progress: &ProgressObject) {
|
||||
let last_update_time = progress
|
||||
.last_update_time
|
||||
.swap(Instant::now(), Ordering::SeqCst);
|
||||
let time_since_last_update = Instant::now().duration_since(last_update_time).as_millis();
|
||||
let time_since_last_update = Instant::now().duration_since(last_update_time).as_millis_f64();
|
||||
|
||||
let current_bytes_downloaded = progress.sum();
|
||||
let max = progress.get_max();
|
||||
@ -128,17 +125,17 @@ pub fn calculate_update(progress: &ProgressObject) {
|
||||
.bytes_last_update
|
||||
.swap(current_bytes_downloaded, Ordering::Acquire);
|
||||
|
||||
let bytes_since_last_update = current_bytes_downloaded.saturating_sub(bytes_at_last_update);
|
||||
let bytes_since_last_update = current_bytes_downloaded.saturating_sub(bytes_at_last_update) as f64;
|
||||
|
||||
let kilobytes_per_second = bytes_since_last_update / (time_since_last_update as usize).max(1);
|
||||
let kilobytes_per_second = bytes_since_last_update / time_since_last_update;
|
||||
|
||||
let bytes_remaining = max.saturating_sub(current_bytes_downloaded); // bytes
|
||||
|
||||
progress.update_window(kilobytes_per_second);
|
||||
progress.update_window(kilobytes_per_second as usize);
|
||||
push_update(progress, bytes_remaining);
|
||||
}
|
||||
|
||||
#[throttle(1, Duration::from_millis(500))]
|
||||
#[throttle(1, Duration::from_millis(250))]
|
||||
pub fn push_update(progress: &ProgressObject, bytes_remaining: usize) {
|
||||
let average_speed = progress.rolling.get_average();
|
||||
let time_remaining = (bytes_remaining / 1000) / average_speed.max(1);
|
||||
@ -148,18 +145,12 @@ pub fn push_update(progress: &ProgressObject, bytes_remaining: usize) {
|
||||
}
|
||||
|
||||
fn update_ui(progress_object: &ProgressObject, kilobytes_per_second: usize, time_remaining: usize) {
|
||||
progress_object
|
||||
.sender
|
||||
.send(DownloadManagerSignal::UpdateUIStats(
|
||||
kilobytes_per_second,
|
||||
time_remaining,
|
||||
))
|
||||
.unwrap();
|
||||
send!(
|
||||
progress_object.sender,
|
||||
DownloadManagerSignal::UpdateUIStats(kilobytes_per_second, time_remaining)
|
||||
);
|
||||
}
|
||||
|
||||
fn update_queue(progress: &ProgressObject) {
|
||||
progress
|
||||
.sender
|
||||
.send(DownloadManagerSignal::UpdateUIQueue)
|
||||
.unwrap();
|
||||
send!(progress.sender, DownloadManagerSignal::UpdateUIQueue)
|
||||
}
|
||||
@ -3,7 +3,8 @@ use std::{
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use crate::database::models::data::DownloadableMetadata;
|
||||
use database::DownloadableMetadata;
|
||||
use utils::lock;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Queue {
|
||||
@ -24,10 +25,10 @@ impl Queue {
|
||||
}
|
||||
}
|
||||
pub fn read(&self) -> VecDeque<DownloadableMetadata> {
|
||||
self.inner.lock().unwrap().clone()
|
||||
lock!(self.inner).clone()
|
||||
}
|
||||
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
|
||||
self.inner.lock().unwrap()
|
||||
lock!(self.inner)
|
||||
}
|
||||
pub fn pop_front(&self) -> Option<DownloadableMetadata> {
|
||||
self.edit().pop_front()
|
||||
@ -1,6 +1,6 @@
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -22,17 +22,22 @@ impl<const S: usize> RollingProgressWindow<S> {
|
||||
}
|
||||
pub fn get_average(&self) -> usize {
|
||||
let current = self.current.load(Ordering::SeqCst);
|
||||
self.window
|
||||
let valid = self
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| i < ¤t)
|
||||
.map(|(_, x)| x.load(Ordering::Acquire))
|
||||
.sum::<usize>()
|
||||
/ S
|
||||
.collect::<Vec<usize>>();
|
||||
let amount = valid.len();
|
||||
let sum = valid.into_iter().sum::<usize>();
|
||||
|
||||
sum / amount
|
||||
}
|
||||
pub fn reset(&self) {
|
||||
self.window
|
||||
.iter()
|
||||
.for_each(|x| x.store(0, Ordering::Release));
|
||||
self.current.store(0, Ordering::Release);
|
||||
}
|
||||
}
|
||||
26
src-tauri/games/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "games"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
atomic-instant-full = "0.1.0"
|
||||
bitcode = "0.6.7"
|
||||
boxcar = "0.2.14"
|
||||
database = { version = "0.1.0", path = "../database" }
|
||||
download_manager = { version = "0.1.0", path = "../download_manager" }
|
||||
hex = "0.4.3"
|
||||
log = "0.4.28"
|
||||
md5 = "0.8.0"
|
||||
rayon = "1.11.0"
|
||||
remote = { version = "0.1.0", path = "../remote" }
|
||||
reqwest = "0.12.23"
|
||||
rustix = "1.1.2"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_with = "3.15.0"
|
||||
sysinfo = "0.37.2"
|
||||
tauri = "2.8.5"
|
||||
throttle_my_fn = "0.2.6"
|
||||
utils = { version = "0.1.0", path = "../utils" }
|
||||
native_model = { version = "0.6.4", features = ["rmp_serde_1_3"], git = "https://github.com/Drop-OSS/native_model.git"}
|
||||
serde_json = "1.0.145"
|
||||
@ -1,10 +1,11 @@
|
||||
use bitcode::{Decode, Encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::games::library::Game;
|
||||
use crate::library::Game;
|
||||
|
||||
pub type Collections = Vec<Collection>;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Collection {
|
||||
id: String,
|
||||
@ -14,7 +15,7 @@ pub struct Collection {
|
||||
entries: Vec<CollectionObject>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CollectionObject {
|
||||
collection_id: String,
|
||||
@ -1,2 +1 @@
|
||||
pub mod collection;
|
||||
pub mod commands;
|
||||
@ -1,29 +1,19 @@
|
||||
use crate::auth::generate_authorization_header;
|
||||
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
|
||||
use crate::database::models::data::{
|
||||
ApplicationTransientStatus, DownloadType, DownloadableMetadata,
|
||||
};
|
||||
use crate::download_manager::download_manager_frontend::{DownloadManagerSignal, DownloadStatus};
|
||||
use crate::download_manager::downloadable::Downloadable;
|
||||
use crate::download_manager::util::download_thread_control_flag::{
|
||||
DownloadThreadControl, DownloadThreadControlFlag,
|
||||
};
|
||||
use crate::download_manager::util::progress_object::{ProgressHandle, ProgressObject};
|
||||
use crate::error::application_download_error::ApplicationDownloadError;
|
||||
use crate::error::remote_access_error::RemoteAccessError;
|
||||
use crate::games::downloads::manifest::{
|
||||
DownloadBucket, DownloadContext, DownloadDrop, DropManifest, DropValidateContext, ManifestBody,
|
||||
};
|
||||
use crate::games::downloads::validate::validate_game_chunk;
|
||||
use crate::games::library::{on_game_complete, push_game_update, set_partially_installed};
|
||||
use crate::games::state::GameStatusManager;
|
||||
use crate::process::utils::get_disk_available;
|
||||
use crate::remote::requests::generate_url;
|
||||
use crate::remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC};
|
||||
use database::{borrow_db_checked, borrow_db_mut_checked, ApplicationTransientStatus, DownloadType, DownloadableMetadata};
|
||||
use download_manager::download_manager_frontend::{DownloadManagerSignal, DownloadStatus};
|
||||
use download_manager::downloadable::Downloadable;
|
||||
use download_manager::error::ApplicationDownloadError;
|
||||
use download_manager::util::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
|
||||
use download_manager::util::progress_object::{ProgressHandle, ProgressObject};
|
||||
use log::{debug, error, info, warn};
|
||||
use rayon::ThreadPoolBuilder;
|
||||
use remote::auth::generate_authorization_header;
|
||||
use remote::error::RemoteAccessError;
|
||||
use remote::requests::generate_url;
|
||||
use remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC};
|
||||
use utils::{app_emit, lock, send};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{create_dir_all, OpenOptions};
|
||||
use std::fs::{OpenOptions, create_dir_all};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@ -33,12 +23,19 @@ use tauri::{AppHandle, Emitter};
|
||||
#[cfg(target_os = "linux")]
|
||||
use rustix::fs::{FallocateFlags, fallocate};
|
||||
|
||||
use crate::downloads::manifest::{DownloadBucket, DownloadContext, DownloadDrop, DropManifest, DropValidateContext, ManifestBody};
|
||||
use crate::downloads::utils::get_disk_available;
|
||||
use crate::downloads::validate::validate_game_chunk;
|
||||
use crate::library::{on_game_complete, push_game_update, set_partially_installed};
|
||||
use crate::state::GameStatusManager;
|
||||
|
||||
use super::download_logic::download_game_bucket;
|
||||
use super::drop_data::DropData;
|
||||
|
||||
static RETRY_COUNT: usize = 3;
|
||||
|
||||
const TARGET_BUCKET_SIZE: usize = 63 * 1000 * 1000;
|
||||
const MAX_FILES_PER_BUCKET: usize = (1024 / 4) - 1;
|
||||
|
||||
pub struct GameDownloadAgent {
|
||||
pub id: String,
|
||||
@ -83,6 +80,8 @@ impl GameDownloadAgent {
|
||||
let stored_manifest =
|
||||
DropData::generate(id.clone(), version.clone(), data_base_dir_path.clone());
|
||||
|
||||
let context_lock = stored_manifest.contexts.lock().unwrap().clone();
|
||||
|
||||
let result = Self {
|
||||
id,
|
||||
version,
|
||||
@ -98,14 +97,19 @@ impl GameDownloadAgent {
|
||||
|
||||
result.ensure_manifest_exists().await?;
|
||||
|
||||
let required_space = result
|
||||
.manifest
|
||||
.lock()
|
||||
.unwrap()
|
||||
let required_space = lock!(result
|
||||
.manifest)
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(|e| e.lengths.iter().sum::<usize>())
|
||||
.map(|e| {
|
||||
e.lengths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *context_lock.get(&e.checksums[*i]).unwrap_or(&false))
|
||||
.map(|(_, v)| v)
|
||||
.sum::<usize>()
|
||||
})
|
||||
.sum::<usize>() as u64;
|
||||
|
||||
let available_space = get_disk_available(data_base_dir_path)? as u64;
|
||||
@ -162,11 +166,11 @@ impl GameDownloadAgent {
|
||||
}
|
||||
|
||||
pub fn check_manifest_exists(&self) -> bool {
|
||||
self.manifest.lock().unwrap().is_some()
|
||||
lock!(self.manifest).is_some()
|
||||
}
|
||||
|
||||
pub async fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
|
||||
if self.manifest.lock().unwrap().is_some() {
|
||||
if lock!(self.manifest).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@ -197,7 +201,10 @@ impl GameDownloadAgent {
|
||||
));
|
||||
}
|
||||
|
||||
let manifest_download: DropManifest = response.json().await.unwrap();
|
||||
let manifest_download: DropManifest = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
|
||||
|
||||
if let Ok(mut manifest) = self.manifest.lock() {
|
||||
*manifest = Some(manifest_download);
|
||||
@ -209,7 +216,7 @@ impl GameDownloadAgent {
|
||||
|
||||
// Sets it up for both download and validate
|
||||
fn setup_progress(&self) {
|
||||
let buckets = self.buckets.lock().unwrap();
|
||||
let buckets = lock!(self.buckets);
|
||||
|
||||
let chunk_count = buckets.iter().map(|e| e.drops.len()).sum();
|
||||
|
||||
@ -224,21 +231,23 @@ impl GameDownloadAgent {
|
||||
}
|
||||
|
||||
pub fn ensure_buckets(&self) -> Result<(), ApplicationDownloadError> {
|
||||
if self.buckets.lock().unwrap().is_empty() {
|
||||
if lock!(self.buckets).is_empty() {
|
||||
self.generate_buckets()?;
|
||||
}
|
||||
|
||||
*self.context_map.lock().unwrap() = self.dropdata.get_contexts();
|
||||
*lock!(self.context_map) = self.dropdata.get_contexts();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_buckets(&self) -> Result<(), ApplicationDownloadError> {
|
||||
let manifest = self.manifest.lock().unwrap().clone().unwrap();
|
||||
let manifest = lock!(self.manifest)
|
||||
.clone()
|
||||
.ok_or(ApplicationDownloadError::NotInitialized)?;
|
||||
let game_id = self.id.clone();
|
||||
|
||||
let base_path = Path::new(&self.dropdata.base_path);
|
||||
create_dir_all(base_path).unwrap();
|
||||
create_dir_all(base_path)?;
|
||||
|
||||
let mut buckets = Vec::new();
|
||||
|
||||
@ -248,8 +257,13 @@ impl GameDownloadAgent {
|
||||
for (raw_path, chunk) in manifest {
|
||||
let path = base_path.join(Path::new(&raw_path));
|
||||
|
||||
let container = path.parent().unwrap();
|
||||
create_dir_all(container).unwrap();
|
||||
let container = path
|
||||
.parent()
|
||||
.ok_or(ApplicationDownloadError::IoError(Arc::new(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"no parent directory",
|
||||
))))?;
|
||||
create_dir_all(container)?;
|
||||
|
||||
let already_exists = path.exists();
|
||||
let file = OpenOptions::new()
|
||||
@ -257,8 +271,7 @@ impl GameDownloadAgent {
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path.clone())
|
||||
.unwrap();
|
||||
.open(&path)?;
|
||||
let mut file_running_offset = 0;
|
||||
|
||||
for (index, length) in chunk.lengths.iter().enumerate() {
|
||||
@ -298,7 +311,8 @@ impl GameDownloadAgent {
|
||||
drops: vec![],
|
||||
});
|
||||
|
||||
if *current_bucket_size + length >= TARGET_BUCKET_SIZE
|
||||
if (*current_bucket_size + length >= TARGET_BUCKET_SIZE
|
||||
|| current_bucket.drops.len() >= MAX_FILES_PER_BUCKET)
|
||||
&& !current_bucket.drops.is_empty()
|
||||
{
|
||||
// Move current bucket into list and make a new one
|
||||
@ -341,7 +355,7 @@ impl GameDownloadAgent {
|
||||
.collect::<Vec<(String, bool)>>(),
|
||||
);
|
||||
|
||||
*self.buckets.lock().unwrap() = buckets;
|
||||
*lock!(self.buckets) = buckets;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -357,9 +371,11 @@ impl GameDownloadAgent {
|
||||
let pool = ThreadPoolBuilder::new()
|
||||
.num_threads(max_download_threads)
|
||||
.build()
|
||||
.unwrap();
|
||||
.unwrap_or_else(|_| {
|
||||
panic!("failed to build thread pool with {max_download_threads} threads")
|
||||
});
|
||||
|
||||
let buckets = self.buckets.lock().unwrap();
|
||||
let buckets = lock!(self.buckets);
|
||||
|
||||
let mut download_contexts = HashMap::<String, DownloadContext>::new();
|
||||
|
||||
@ -367,7 +383,8 @@ impl GameDownloadAgent {
|
||||
.iter()
|
||||
.map(|e| &e.version)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter().cloned()
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
info!("downloading across these versions: {versions:?}");
|
||||
@ -377,7 +394,7 @@ impl GameDownloadAgent {
|
||||
|
||||
for version in versions {
|
||||
let download_context = DROP_CLIENT_SYNC
|
||||
.post(generate_url(&["/api/v2/client/context"], &[]).unwrap())
|
||||
.post(generate_url(&["/api/v2/client/context"], &[])?)
|
||||
.json(&ManifestBody {
|
||||
game: self.id.clone(),
|
||||
version: version.clone(),
|
||||
@ -400,7 +417,7 @@ impl GameDownloadAgent {
|
||||
let download_contexts = &download_contexts;
|
||||
|
||||
pool.scope(|scope| {
|
||||
let context_map = self.context_map.lock().unwrap();
|
||||
let context_map = lock!(self.context_map);
|
||||
for (index, bucket) in buckets.iter().enumerate() {
|
||||
let mut bucket = (*bucket).clone();
|
||||
let completed_contexts = completed_indexes_loop_arc.clone();
|
||||
@ -432,8 +449,7 @@ impl GameDownloadAgent {
|
||||
|
||||
let download_context = download_contexts
|
||||
.get(&bucket.version)
|
||||
.ok_or(RemoteAccessError::CorruptedState)
|
||||
.unwrap();
|
||||
.unwrap_or_else(|| panic!("Could not get bucket version {}. Corrupted state.", bucket.version));
|
||||
|
||||
scope.spawn(move |_| {
|
||||
// 3 attempts
|
||||
@ -460,11 +476,12 @@ impl GameDownloadAgent {
|
||||
ApplicationDownloadError::Communication(_)
|
||||
| ApplicationDownloadError::Checksum
|
||||
| ApplicationDownloadError::Lock
|
||||
| ApplicationDownloadError::IoError(_)
|
||||
);
|
||||
|
||||
if i == RETRY_COUNT - 1 || !retry {
|
||||
warn!("retry logic failed, not re-attempting.");
|
||||
sender.send(DownloadManagerSignal::Error(e)).unwrap();
|
||||
send!(sender, DownloadManagerSignal::Error(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -477,7 +494,7 @@ impl GameDownloadAgent {
|
||||
let newly_completed = completed_contexts.clone();
|
||||
|
||||
let completed_lock_len = {
|
||||
let mut context_map_lock = self.context_map.lock().unwrap();
|
||||
let mut context_map_lock = lock!(self.context_map);
|
||||
for (_, item) in newly_completed.iter() {
|
||||
context_map_lock.insert(item.clone(), true);
|
||||
}
|
||||
@ -485,7 +502,7 @@ impl GameDownloadAgent {
|
||||
context_map_lock.values().filter(|x| **x).count()
|
||||
};
|
||||
|
||||
let context_map_lock = self.context_map.lock().unwrap();
|
||||
let context_map_lock = lock!(self.context_map);
|
||||
let contexts = buckets
|
||||
.iter()
|
||||
.flat_map(|x| x.drops.iter().map(|e| e.checksum.clone()))
|
||||
@ -534,7 +551,7 @@ impl GameDownloadAgent {
|
||||
pub fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
|
||||
self.setup_validate(app_handle);
|
||||
|
||||
let buckets = self.buckets.lock().unwrap();
|
||||
let buckets = lock!(self.buckets);
|
||||
let contexts: Vec<DropValidateContext> = buckets
|
||||
.clone()
|
||||
.into_iter()
|
||||
@ -546,7 +563,9 @@ impl GameDownloadAgent {
|
||||
let pool = ThreadPoolBuilder::new()
|
||||
.num_threads(max_download_threads)
|
||||
.build()
|
||||
.unwrap();
|
||||
.unwrap_or_else(|_| {
|
||||
panic!("failed to build thread pool with {max_download_threads} threads")
|
||||
});
|
||||
|
||||
let invalid_chunks = Arc::new(boxcar::Vec::new());
|
||||
pool.scope(|scope| {
|
||||
@ -564,7 +583,7 @@ impl GameDownloadAgent {
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
sender.send(DownloadManagerSignal::Error(e)).unwrap();
|
||||
send!(sender, DownloadManagerSignal::Error(e));
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -591,7 +610,7 @@ impl GameDownloadAgent {
|
||||
// See docs on usage
|
||||
set_partially_installed(
|
||||
&self.metadata(),
|
||||
self.dropdata.base_path.to_str().unwrap().to_string(),
|
||||
self.dropdata.base_path.display().to_string(),
|
||||
Some(app_handle),
|
||||
);
|
||||
|
||||
@ -601,12 +620,12 @@ impl GameDownloadAgent {
|
||||
|
||||
impl Downloadable for GameDownloadAgent {
|
||||
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
|
||||
*self.status.lock().unwrap() = DownloadStatus::Downloading;
|
||||
*lock!(self.status) = DownloadStatus::Downloading;
|
||||
self.download(app_handle)
|
||||
}
|
||||
|
||||
fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
|
||||
*self.status.lock().unwrap() = DownloadStatus::Validating;
|
||||
*lock!(self.status) = DownloadStatus::Validating;
|
||||
self.validate(app_handle)
|
||||
}
|
||||
|
||||
@ -626,17 +645,24 @@ impl Downloadable for GameDownloadAgent {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_initialised(&self, _app_handle: &tauri::AppHandle) {
|
||||
fn on_queued(&self, app_handle: &tauri::AppHandle) {
|
||||
*self.status.lock().unwrap() = DownloadStatus::Queued;
|
||||
let mut db_lock = borrow_db_mut_checked();
|
||||
let status = ApplicationTransientStatus::Queued {
|
||||
version_name: self.version.clone(),
|
||||
};
|
||||
db_lock
|
||||
.applications
|
||||
.transient_statuses
|
||||
.insert(self.metadata(), status.clone());
|
||||
push_game_update(app_handle, &self.id, None, (None, Some(status)));
|
||||
}
|
||||
|
||||
fn on_error(&self, app_handle: &tauri::AppHandle, error: &ApplicationDownloadError) {
|
||||
*self.status.lock().unwrap() = DownloadStatus::Error;
|
||||
app_handle
|
||||
.emit("download_error", error.to_string())
|
||||
.unwrap();
|
||||
*lock!(self.status) = DownloadStatus::Error;
|
||||
app_emit!(app_handle, "download_error", error.to_string());
|
||||
|
||||
error!("error while managing download: {error}");
|
||||
error!("error while managing download: {error:?}");
|
||||
|
||||
let mut handle = borrow_db_mut_checked();
|
||||
handle
|
||||
@ -653,27 +679,25 @@ impl Downloadable for GameDownloadAgent {
|
||||
}
|
||||
|
||||
fn on_complete(&self, app_handle: &tauri::AppHandle) {
|
||||
on_game_complete(
|
||||
match on_game_complete(
|
||||
&self.metadata(),
|
||||
self.dropdata.base_path.to_string_lossy().to_string(),
|
||||
app_handle,
|
||||
)
|
||||
.unwrap();
|
||||
) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("could not mark game as complete: {e}");
|
||||
send!(self.sender, DownloadManagerSignal::Error(ApplicationDownloadError::DownloadError(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_cancelled(&self, app_handle: &tauri::AppHandle) {
|
||||
info!("cancelled {}", self.id);
|
||||
self.cancel(app_handle);
|
||||
/*
|
||||
on_game_incomplete(
|
||||
&self.metadata(),
|
||||
self.dropdata.base_path.to_string_lossy().to_string(),
|
||||
app_handle,
|
||||
)
|
||||
.unwrap();
|
||||
*/
|
||||
}
|
||||
|
||||
fn status(&self) -> DownloadStatus {
|
||||
self.status.lock().unwrap().clone()
|
||||
lock!(self.status).clone()
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,30 @@
|
||||
use crate::download_manager::util::download_thread_control_flag::{
|
||||
DownloadThreadControl, DownloadThreadControlFlag,
|
||||
};
|
||||
use crate::download_manager::util::progress_object::ProgressHandle;
|
||||
use crate::error::application_download_error::ApplicationDownloadError;
|
||||
use crate::error::drop_server_error::DropServerError;
|
||||
use crate::error::remote_access_error::RemoteAccessError;
|
||||
use crate::games::downloads::manifest::{ChunkBody, DownloadBucket, DownloadContext, DownloadDrop};
|
||||
use crate::remote::auth::generate_authorization_header;
|
||||
use crate::remote::requests::generate_url;
|
||||
use crate::remote::utils::DROP_CLIENT_SYNC;
|
||||
use log::{info, warn};
|
||||
use md5::{Context, Digest};
|
||||
use reqwest::blocking::Response;
|
||||
|
||||
use std::fs::{Permissions, set_permissions};
|
||||
use std::io::Read;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::{self, BufWriter, Seek, SeekFrom, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use download_manager::error::ApplicationDownloadError;
|
||||
use download_manager::util::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
|
||||
use download_manager::util::progress_object::ProgressHandle;
|
||||
use log::{debug, info, warn};
|
||||
use md5::{Context, Digest};
|
||||
use remote::auth::generate_authorization_header;
|
||||
use remote::error::{DropServerError, RemoteAccessError};
|
||||
use remote::requests::generate_url;
|
||||
use remote::utils::DROP_CLIENT_SYNC;
|
||||
use reqwest::blocking::Response;
|
||||
|
||||
use crate::downloads::manifest::{ChunkBody, DownloadBucket, DownloadContext, DownloadDrop};
|
||||
|
||||
static MAX_PACKET_LENGTH: usize = 4096 * 4;
|
||||
static BUMP_SIZE: usize = 4096 * 16;
|
||||
|
||||
pub struct DropWriter<W: Write> {
|
||||
hasher: Context,
|
||||
@ -79,6 +79,8 @@ pub struct DropDownloadPipeline<'a, R: Read, W: Write> {
|
||||
pub drops: Vec<DownloadDrop>,
|
||||
pub destination: Vec<DropWriter<W>>,
|
||||
pub control_flag: &'a DownloadThreadControl,
|
||||
#[allow(dead_code)]
|
||||
progress: ProgressHandle,
|
||||
}
|
||||
|
||||
impl<'a> DropDownloadPipeline<'a, Response, File> {
|
||||
@ -96,6 +98,7 @@ impl<'a> DropDownloadPipeline<'a, Response, File> {
|
||||
.try_collect()?,
|
||||
drops,
|
||||
control_flag,
|
||||
progress,
|
||||
})
|
||||
}
|
||||
|
||||
@ -105,19 +108,29 @@ impl<'a> DropDownloadPipeline<'a, Response, File> {
|
||||
let destination = self
|
||||
.destination
|
||||
.get_mut(index)
|
||||
.ok_or(io::Error::other("no destination"))
|
||||
.unwrap();
|
||||
.ok_or(io::Error::other("no destination"))?;
|
||||
let mut remaining = drop.length;
|
||||
if drop.start != 0 {
|
||||
destination.seek(SeekFrom::Start(drop.start.try_into().unwrap()))?;
|
||||
destination.seek(SeekFrom::Start(drop.start as u64))?;
|
||||
}
|
||||
let mut last_bump = 0;
|
||||
loop {
|
||||
let size = MAX_PACKET_LENGTH.min(remaining);
|
||||
self.source.read_exact(&mut copy_buffer[0..size])?;
|
||||
let size = self.source.read(&mut copy_buffer[0..size]).inspect_err(|_| {
|
||||
info!("got error from {}", drop.filename);
|
||||
})?;
|
||||
remaining -= size;
|
||||
last_bump += size;
|
||||
|
||||
destination.write_all(©_buffer[0..size])?;
|
||||
|
||||
if last_bump > BUMP_SIZE {
|
||||
last_bump -= BUMP_SIZE;
|
||||
if self.control_flag.get() == DownloadThreadControlFlag::Stop {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
if remaining == 0 {
|
||||
break;
|
||||
};
|
||||
@ -131,6 +144,13 @@ impl<'a> DropDownloadPipeline<'a, Response, File> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn debug_skip_checksum(self) {
|
||||
self.destination
|
||||
.into_iter()
|
||||
.for_each(|mut e| e.flush().unwrap());
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<Vec<Digest>, io::Error> {
|
||||
let checksums = self
|
||||
.destination
|
||||
@ -153,6 +173,8 @@ pub fn download_game_bucket(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let header = generate_authorization_header();
|
||||
|
||||
let url = generate_url(&["/api/v2/client/chunk"], &[])
|
||||
@ -190,25 +212,46 @@ pub fn download_game_bucket(
|
||||
RemoteAccessError::UnparseableResponse("missing Content-Lengths header".to_owned()),
|
||||
))?
|
||||
.to_str()
|
||||
.unwrap();
|
||||
.map_err(|e| {
|
||||
ApplicationDownloadError::Communication(RemoteAccessError::UnparseableResponse(
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
for (i, raw_length) in lengths.split(",").enumerate() {
|
||||
let length = raw_length.parse::<usize>().unwrap_or(0);
|
||||
let Some(drop) = bucket.drops.get(i) else {
|
||||
warn!(
|
||||
"invalid number of Content-Lengths recieved: {i}, {lengths}"
|
||||
);
|
||||
return Err(ApplicationDownloadError::DownloadError);
|
||||
warn!("invalid number of Content-Lengths recieved: {i}, {lengths}");
|
||||
return Err(ApplicationDownloadError::DownloadError(
|
||||
RemoteAccessError::InvalidResponse(DropServerError {
|
||||
status_code: 400,
|
||||
status_message: format!(
|
||||
"invalid number of Content-Lengths recieved: {i}, {lengths}"
|
||||
),
|
||||
}),
|
||||
));
|
||||
};
|
||||
if drop.length != length {
|
||||
warn!(
|
||||
"for {}, expected {}, got {} ({})",
|
||||
drop.filename, drop.length, raw_length, length
|
||||
);
|
||||
return Err(ApplicationDownloadError::DownloadError);
|
||||
return Err(ApplicationDownloadError::DownloadError(
|
||||
RemoteAccessError::InvalidResponse(DropServerError {
|
||||
status_code: 400,
|
||||
status_message: format!(
|
||||
"for {}, expected {}, got {} ({})",
|
||||
drop.filename, drop.length, raw_length, length
|
||||
),
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let timestep = start.elapsed().as_millis();
|
||||
|
||||
debug!("took {}ms to start downloading", timestep);
|
||||
|
||||
let mut pipeline =
|
||||
DropDownloadPipeline::new(response, bucket.drops.clone(), control_flag, progress)
|
||||
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
|
||||
@ -4,6 +4,7 @@ use std::{
|
||||
|
||||
use log::error;
|
||||
use native_model::{Decode, Encode};
|
||||
use utils::lock;
|
||||
|
||||
pub type DropData = v1::DropData;
|
||||
|
||||
@ -49,7 +50,12 @@ impl DropData {
|
||||
let mut s = Vec::new();
|
||||
file.read_to_end(&mut s)?;
|
||||
|
||||
Ok(native_model::rmp_serde_1_3::RmpSerde::decode(s).unwrap())
|
||||
native_model::rmp_serde_1_3::RmpSerde::decode(s).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Failed to decode drop data: {e}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
pub fn write(&self) {
|
||||
let manifest_raw = match native_model::rmp_serde_1_3::RmpSerde::encode(&self) {
|
||||
@ -71,12 +77,12 @@ impl DropData {
|
||||
}
|
||||
}
|
||||
pub fn set_contexts(&self, completed_contexts: &[(String, bool)]) {
|
||||
*self.contexts.lock().unwrap() = completed_contexts.iter().map(|s| (s.0.clone(), s.1)).collect();
|
||||
*lock!(self.contexts) = completed_contexts.iter().map(|s| (s.0.clone(), s.1)).collect();
|
||||
}
|
||||
pub fn set_context(&self, context: String, state: bool) {
|
||||
self.contexts.lock().unwrap().entry(context).insert_entry(state);
|
||||
lock!(self.contexts).entry(context).insert_entry(state);
|
||||
}
|
||||
pub fn get_contexts(&self) -> HashMap<String, bool> {
|
||||
self.contexts.lock().unwrap().clone()
|
||||
lock!(self.contexts).clone()
|
||||
}
|
||||
}
|
||||
21
src-tauri/games/src/downloads/error.rs
Normal file
@ -0,0 +1,21 @@
|
||||
use std::fmt::{Display};
|
||||
|
||||
use serde_with::SerializeDisplay;
|
||||
|
||||
#[derive(SerializeDisplay)]
|
||||
pub enum LibraryError {
|
||||
MetaNotFound(String),
|
||||
VersionNotFound(String),
|
||||
}
|
||||
impl Display for LibraryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", match self {
|
||||
LibraryError::MetaNotFound(id) => {
|
||||
format!("Could not locate any installed version of game ID {id} in the database")
|
||||
}
|
||||
LibraryError::VersionNotFound(game_id) => {
|
||||
format!("Could not locate any installed version for game id {game_id} in the database")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
pub mod commands;
|
||||
pub mod download_agent;
|
||||
mod download_logic;
|
||||
pub mod drop_data;
|
||||
pub mod error;
|
||||
mod manifest;
|
||||
pub mod validate;
|
||||
pub mod utils;
|
||||
@ -1,10 +1,8 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use std::{io, path::PathBuf, sync::Arc};
|
||||
|
||||
use futures_lite::io;
|
||||
use download_manager::error::ApplicationDownloadError;
|
||||
use sysinfo::{Disk, DiskRefreshKind, Disks};
|
||||
|
||||
use crate::error::application_download_error::ApplicationDownloadError;
|
||||
|
||||
pub fn get_disk_available(mount_point: PathBuf) -> Result<u64, ApplicationDownloadError> {
|
||||
let disks = Disks::new_with_refreshed_list_specifics(DiskRefreshKind::nothing().with_storage());
|
||||
|
||||
@ -3,17 +3,11 @@ use std::{
|
||||
io::{self, BufWriter, Read, Seek, SeekFrom, Write},
|
||||
};
|
||||
|
||||
use download_manager::{error::ApplicationDownloadError, util::{download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, progress_object::ProgressHandle}};
|
||||
use log::debug;
|
||||
use md5::Context;
|
||||
|
||||
use crate::{
|
||||
download_manager::util::{
|
||||
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
|
||||
progress_object::ProgressHandle,
|
||||
},
|
||||
error::application_download_error::ApplicationDownloadError,
|
||||
games::downloads::manifest::DropValidateContext,
|
||||
};
|
||||
use crate::downloads::manifest::DropValidateContext;
|
||||
|
||||
pub fn validate_game_chunk(
|
||||
ctx: &DropValidateContext,
|
||||
@ -36,14 +30,14 @@ pub fn validate_game_chunk(
|
||||
|
||||
if ctx.offset != 0 {
|
||||
source
|
||||
.seek(SeekFrom::Start(ctx.offset.try_into().unwrap()))
|
||||
.seek(SeekFrom::Start(ctx.offset as u64))
|
||||
.expect("Failed to seek to file offset");
|
||||
}
|
||||
|
||||
let mut hasher = md5::Context::new();
|
||||
|
||||
let completed =
|
||||
validate_copy(&mut source, &mut hasher, ctx.length, control_flag, progress).unwrap();
|
||||
validate_copy(&mut source, &mut hasher, ctx.length, control_flag, progress)?;
|
||||
if !completed {
|
||||
return Ok(false);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
#![feature(iterator_try_collect)]
|
||||
|
||||
pub mod collections;
|
||||
pub mod commands;
|
||||
pub mod downloads;
|
||||
pub mod library;
|
||||
pub mod state;
|
||||
313
src-tauri/games/src/library.rs
Normal file
@ -0,0 +1,313 @@
|
||||
use std::fs::remove_dir_all;
|
||||
use std::sync::Mutex;
|
||||
use std::thread::spawn;
|
||||
use bitcode::{Decode, Encode};
|
||||
use database::{borrow_db_checked, borrow_db_mut_checked, ApplicationTransientStatus, Database, DownloadableMetadata, GameDownloadStatus, GameVersion};
|
||||
use log::{debug, error, warn};
|
||||
use remote::{auth::generate_authorization_header, error::RemoteAccessError, requests::generate_url, utils::DROP_CLIENT_SYNC};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use utils::app_emit;
|
||||
|
||||
use crate::{downloads::error::LibraryError, state::{GameStatusManager, GameStatusWithTransient}};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FetchGameStruct {
|
||||
game: Game,
|
||||
status: GameStatusWithTransient,
|
||||
version: Option<GameVersion>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, Encode, Decode)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Game {
|
||||
id: String,
|
||||
m_name: String,
|
||||
m_short_description: String,
|
||||
m_description: String,
|
||||
// mDevelopers
|
||||
// mPublishers
|
||||
m_icon_object_id: String,
|
||||
m_banner_object_id: String,
|
||||
m_cover_object_id: String,
|
||||
m_image_library_object_ids: Vec<String>,
|
||||
m_image_carousel_object_ids: Vec<String>,
|
||||
}
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
pub struct GameUpdateEvent {
|
||||
pub game_id: String,
|
||||
pub status: (
|
||||
Option<GameDownloadStatus>,
|
||||
Option<ApplicationTransientStatus>,
|
||||
),
|
||||
pub version: Option<GameVersion>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by:
|
||||
* - on_cancel, when cancelled, for obvious reasons
|
||||
* - when downloading, so if drop unexpectedly quits, we can resume the download. hidden by the "Downloading..." transient state, though
|
||||
* - when scanning, to import the game
|
||||
*/
|
||||
pub fn set_partially_installed(
|
||||
meta: &DownloadableMetadata,
|
||||
install_dir: String,
|
||||
app_handle: Option<&AppHandle>,
|
||||
) {
|
||||
set_partially_installed_db(&mut borrow_db_mut_checked(), meta, install_dir, app_handle);
|
||||
}
|
||||
|
||||
pub fn set_partially_installed_db(
|
||||
db_lock: &mut Database,
|
||||
meta: &DownloadableMetadata,
|
||||
install_dir: String,
|
||||
app_handle: Option<&AppHandle>,
|
||||
) {
|
||||
db_lock.applications.transient_statuses.remove(meta);
|
||||
db_lock.applications.game_statuses.insert(
|
||||
meta.id.clone(),
|
||||
GameDownloadStatus::PartiallyInstalled {
|
||||
version_name: meta.version.as_ref().unwrap().clone(),
|
||||
install_dir,
|
||||
},
|
||||
);
|
||||
db_lock
|
||||
.applications
|
||||
.installed_game_version
|
||||
.insert(meta.id.clone(), meta.clone());
|
||||
|
||||
if let Some(app_handle) = app_handle {
|
||||
push_game_update(
|
||||
app_handle,
|
||||
&meta.id,
|
||||
None,
|
||||
GameStatusManager::fetch_state(&meta.id, db_lock),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) {
|
||||
debug!("triggered uninstall for agent");
|
||||
let mut db_handle = borrow_db_mut_checked();
|
||||
db_handle
|
||||
.applications
|
||||
.transient_statuses
|
||||
.insert(meta.clone(), ApplicationTransientStatus::Uninstalling {});
|
||||
|
||||
push_game_update(
|
||||
app_handle,
|
||||
&meta.id,
|
||||
None,
|
||||
GameStatusManager::fetch_state(&meta.id, &db_handle),
|
||||
);
|
||||
|
||||
let previous_state = db_handle.applications.game_statuses.get(&meta.id).cloned();
|
||||
|
||||
let previous_state = if let Some(state) = previous_state {
|
||||
state
|
||||
} else {
|
||||
warn!("uninstall job doesn't have previous state, failing silently");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some((_, install_dir)) = match previous_state {
|
||||
GameDownloadStatus::Installed {
|
||||
version_name,
|
||||
install_dir,
|
||||
} => Some((version_name, install_dir)),
|
||||
GameDownloadStatus::SetupRequired {
|
||||
version_name,
|
||||
install_dir,
|
||||
} => Some((version_name, install_dir)),
|
||||
GameDownloadStatus::PartiallyInstalled {
|
||||
version_name,
|
||||
install_dir,
|
||||
} => Some((version_name, install_dir)),
|
||||
_ => None,
|
||||
} {
|
||||
db_handle
|
||||
.applications
|
||||
.transient_statuses
|
||||
.insert(meta.clone(), ApplicationTransientStatus::Uninstalling {});
|
||||
|
||||
drop(db_handle);
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
spawn(move || {
|
||||
if let Err(e) = remove_dir_all(install_dir) {
|
||||
error!("{e}");
|
||||
} else {
|
||||
let mut db_handle = borrow_db_mut_checked();
|
||||
db_handle.applications.transient_statuses.remove(&meta);
|
||||
db_handle
|
||||
.applications
|
||||
.installed_game_version
|
||||
.remove(&meta.id);
|
||||
db_handle
|
||||
.applications
|
||||
.game_statuses
|
||||
.insert(meta.id.clone(), GameDownloadStatus::Remote {});
|
||||
let _ = db_handle.applications.transient_statuses.remove(&meta);
|
||||
|
||||
push_game_update(
|
||||
&app_handle,
|
||||
&meta.id,
|
||||
None,
|
||||
GameStatusManager::fetch_state(&meta.id, &db_handle),
|
||||
);
|
||||
|
||||
debug!("uninstalled game id {}", &meta.id);
|
||||
app_emit!(app_handle, "update_library", ());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
warn!("invalid previous state for uninstall, failing silently.");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_current_meta(game_id: &String) -> Option<DownloadableMetadata> {
|
||||
borrow_db_checked()
|
||||
.applications
|
||||
.installed_game_version
|
||||
.get(game_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn on_game_complete(
|
||||
meta: &DownloadableMetadata,
|
||||
install_dir: String,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<(), RemoteAccessError> {
|
||||
// Fetch game version information from remote
|
||||
if meta.version.is_none() {
|
||||
return Err(RemoteAccessError::GameNotFound(meta.id.clone()));
|
||||
}
|
||||
|
||||
let client = DROP_CLIENT_SYNC.clone();
|
||||
let response = generate_url(
|
||||
&["/api/v1/client/game/version"],
|
||||
&[
|
||||
("id", &meta.id),
|
||||
("version", meta.version.as_ref().unwrap()),
|
||||
],
|
||||
)?;
|
||||
let response = client
|
||||
.get(response)
|
||||
.header("Authorization", generate_authorization_header())
|
||||
.send()?;
|
||||
|
||||
let game_version: GameVersion = response.json()?;
|
||||
|
||||
let mut handle = borrow_db_mut_checked();
|
||||
handle
|
||||
.applications
|
||||
.game_versions
|
||||
.entry(meta.id.clone())
|
||||
.or_default()
|
||||
.insert(meta.version.clone().unwrap(), game_version.clone());
|
||||
handle
|
||||
.applications
|
||||
.installed_game_version
|
||||
.insert(meta.id.clone(), meta.clone());
|
||||
|
||||
drop(handle);
|
||||
|
||||
let status = if game_version.setup_command.is_empty() {
|
||||
GameDownloadStatus::Installed {
|
||||
version_name: meta.version.clone().unwrap(),
|
||||
install_dir,
|
||||
}
|
||||
} else {
|
||||
GameDownloadStatus::SetupRequired {
|
||||
version_name: meta.version.clone().unwrap(),
|
||||
install_dir,
|
||||
}
|
||||
};
|
||||
|
||||
let mut db_handle = borrow_db_mut_checked();
|
||||
db_handle
|
||||
.applications
|
||||
.game_statuses
|
||||
.insert(meta.id.clone(), status.clone());
|
||||
drop(db_handle);
|
||||
app_emit!(
|
||||
app_handle,
|
||||
&format!("update_game/{}", meta.id),
|
||||
GameUpdateEvent {
|
||||
game_id: meta.id.clone(),
|
||||
status: (Some(status), None),
|
||||
version: Some(game_version),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_game_update(
|
||||
app_handle: &AppHandle,
|
||||
game_id: &String,
|
||||
version: Option<GameVersion>,
|
||||
status: GameStatusWithTransient,
|
||||
) {
|
||||
if let Some(GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }) =
|
||||
&status.0
|
||||
&& version.is_none()
|
||||
{
|
||||
panic!("pushed game for installed game that doesn't have version information");
|
||||
}
|
||||
|
||||
app_emit!(
|
||||
app_handle,
|
||||
&format!("update_game/{game_id}"),
|
||||
GameUpdateEvent {
|
||||
game_id: game_id.clone(),
|
||||
status,
|
||||
version,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrontendGameOptions {
|
||||
launch_string: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_game_configuration(
|
||||
game_id: String,
|
||||
options: FrontendGameOptions,
|
||||
) -> Result<(), LibraryError> {
|
||||
let mut handle = borrow_db_mut_checked();
|
||||
let installed_version = handle
|
||||
.applications
|
||||
.installed_game_version
|
||||
.get(&game_id)
|
||||
.ok_or(LibraryError::MetaNotFound(game_id))?;
|
||||
|
||||
let id = installed_version.id.clone();
|
||||
let version = installed_version.version.clone().ok_or(LibraryError::VersionNotFound(id.clone()))?;
|
||||
|
||||
let mut existing_configuration = handle
|
||||
.applications
|
||||
.game_versions
|
||||
.get(&id)
|
||||
.unwrap()
|
||||
.get(&version)
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
// Add more options in here
|
||||
existing_configuration.launch_command_template = options.launch_string;
|
||||
|
||||
// Add no more options past here
|
||||
|
||||
handle
|
||||
.applications
|
||||
.game_versions
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.insert(version.to_string(), existing_configuration);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -5,10 +5,10 @@ use log::warn;
|
||||
use crate::{
|
||||
database::{
|
||||
db::borrow_db_mut_checked,
|
||||
models::data::v1::{DownloadType, DownloadableMetadata},
|
||||
models::data::{DownloadType, DownloadableMetadata},
|
||||
},
|
||||
games::{
|
||||
downloads::drop_data::{v1::DropData, DROP_DATA_PATH},
|
||||
downloads::drop_data::{DropData, DROP_DATA_PATH},
|
||||
library::set_partially_installed_db,
|
||||
},
|
||||
};
|
||||
@ -24,11 +24,11 @@ pub fn scan_install_dirs() {
|
||||
if !drop_data_file.exists() {
|
||||
continue;
|
||||
}
|
||||
let game_id = game.file_name().into_string().unwrap();
|
||||
let game_id = game.file_name().display().to_string();
|
||||
let Ok(drop_data) = DropData::read(&game.path()) else {
|
||||
warn!(
|
||||
".dropdata exists for {}, but couldn't read it. is it corrupted?",
|
||||
game.file_name().into_string().unwrap()
|
||||
game.file_name().display()
|
||||
);
|
||||
continue;
|
||||
};
|
||||
@ -1,4 +1,6 @@
|
||||
use crate::database::models::data::{ApplicationTransientStatus, Database, GameDownloadStatus};
|
||||
use database::models::data::{
|
||||
ApplicationTransientStatus, Database, DownloadType, DownloadableMetadata, GameDownloadStatus,
|
||||
};
|
||||
|
||||
pub type GameStatusWithTransient = (
|
||||
Option<GameDownloadStatus>,
|
||||
@ -8,10 +10,16 @@ pub struct GameStatusManager {}
|
||||
|
||||
impl GameStatusManager {
|
||||
pub fn fetch_state(game_id: &String, database: &Database) -> GameStatusWithTransient {
|
||||
let online_state = match database.applications.installed_game_version.get(game_id) {
|
||||
Some(meta) => database.applications.transient_statuses.get(meta).cloned(),
|
||||
None => None,
|
||||
};
|
||||
let online_state = database
|
||||
.applications
|
||||
.transient_statuses
|
||||
.get(&DownloadableMetadata {
|
||||
id: game_id.to_string(),
|
||||
download_type: DownloadType::Game,
|
||||
version: None,
|
||||
})
|
||||
.cloned();
|
||||
|
||||
let offline_state = database.applications.game_statuses.get(game_id).cloned();
|
||||
|
||||
if online_state.is_some() {
|
||||
17
src-tauri/process/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "process"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.42"
|
||||
client = { version = "0.1.0", path = "../client" }
|
||||
database = { version = "0.1.0", path = "../database" }
|
||||
dynfmt = "0.1.5"
|
||||
log = "0.4.28"
|
||||
serde = "1.0.228"
|
||||
serde_with = "3.15.0"
|
||||
shared_child = "1.1.1"
|
||||
tauri = "2.8.5"
|
||||
tauri-plugin-opener = "2.5.0"
|
||||
utils = { version = "0.1.0", path = "../utils" }
|
||||
@ -11,7 +11,8 @@ pub enum ProcessError {
|
||||
IOError(Error),
|
||||
FormatError(String), // String errors supremacy
|
||||
InvalidPlatform,
|
||||
OpenerError(tauri_plugin_opener::Error)
|
||||
OpenerError(tauri_plugin_opener::Error),
|
||||
InvalidArguments(String)
|
||||
}
|
||||
|
||||
impl Display for ProcessError {
|
||||
@ -23,8 +24,9 @@ impl Display for ProcessError {
|
||||
ProcessError::InvalidVersion => "Invalid game version",
|
||||
ProcessError::IOError(error) => &error.to_string(),
|
||||
ProcessError::InvalidPlatform => "This game cannot be played on the current platform",
|
||||
ProcessError::FormatError(e) => &format!("Failed to format template: {e}"),
|
||||
ProcessError::OpenerError(error) => &format!("Failed to open directory: {error}"),
|
||||
ProcessError::FormatError(error) => &format!("Could not format template: {error:?}"),
|
||||
ProcessError::OpenerError(error) => &format!("Could not open directory: {error:?}"),
|
||||
ProcessError::InvalidArguments(arguments) => &format!("Invalid arguments in command {arguments}"),
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
pub mod commands;
|
||||
pub mod process_manager;
|
||||
pub mod process_handlers;
|
||||
pub mod format;
|
||||
pub mod utils;
|
||||
pub mod error;
|
||||
@ -5,13 +5,11 @@ use std::{
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use database::{platform::Platform, Database, DownloadableMetadata, GameVersion};
|
||||
use log::{debug, info};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
database::models::data::{Database, DownloadableMetadata, GameVersion},
|
||||
process::process_manager::{Platform, ProcessHandler},
|
||||
};
|
||||
|
||||
use crate::{error::ProcessError, process_manager::ProcessHandler};
|
||||
|
||||
pub struct NativeGameLauncher;
|
||||
impl ProcessHandler for NativeGameLauncher {
|
||||
@ -22,8 +20,8 @@ impl ProcessHandler for NativeGameLauncher {
|
||||
args: Vec<String>,
|
||||
_game_version: &GameVersion,
|
||||
_current_dir: &str,
|
||||
) -> String {
|
||||
format!("\"{}\" {}", launch_command, args.join(" "))
|
||||
) -> Result<String, ProcessError> {
|
||||
Ok(format!("\"{}\" {}", launch_command, args.join(" ")))
|
||||
}
|
||||
|
||||
fn valid_for_platform(&self, _db: &Database, _state: &AppState, _target: &Platform) -> bool {
|
||||
@ -65,7 +63,7 @@ impl ProcessHandler for UMULauncher {
|
||||
args: Vec<String>,
|
||||
game_version: &GameVersion,
|
||||
_current_dir: &str,
|
||||
) -> String {
|
||||
) -> Result<String, ProcessError> {
|
||||
debug!("Game override: \"{:?}\"", &game_version.umu_id_override);
|
||||
let game_id = match &game_version.umu_id_override {
|
||||
Some(game_override) => {
|
||||
@ -77,12 +75,12 @@ impl ProcessHandler for UMULauncher {
|
||||
}
|
||||
None => game_version.game_id.clone(),
|
||||
};
|
||||
format!(
|
||||
Ok(format!(
|
||||
"GAMEID={game_id} {umu:?} \"{launch}\" {args}",
|
||||
umu = UMU_LAUNCHER_EXECUTABLE.as_ref().unwrap(),
|
||||
umu = UMU_LAUNCHER_EXECUTABLE.as_ref().expect("Failed to get UMU_LAUNCHER_EXECUTABLE as ref"),
|
||||
launch = launch_command,
|
||||
args = args.join(" ")
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn valid_for_platform(&self, _db: &Database, state: &AppState, _target: &Platform) -> bool {
|
||||
@ -102,7 +100,7 @@ impl ProcessHandler for AsahiMuvmLauncher {
|
||||
args: Vec<String>,
|
||||
game_version: &GameVersion,
|
||||
current_dir: &str,
|
||||
) -> String {
|
||||
) -> Result<String, ProcessError> {
|
||||
let umu_launcher = UMULauncher {};
|
||||
let umu_string = umu_launcher.create_launch_process(
|
||||
meta,
|
||||
@ -110,15 +108,18 @@ impl ProcessHandler for AsahiMuvmLauncher {
|
||||
args,
|
||||
game_version,
|
||||
current_dir,
|
||||
);
|
||||
)?;
|
||||
let mut args_cmd = umu_string
|
||||
.split("umu-run")
|
||||
.collect::<Vec<&str>>()
|
||||
.into_iter();
|
||||
let args = args_cmd.next().unwrap().trim();
|
||||
let cmd = format!("umu-run{}", args_cmd.next().unwrap());
|
||||
let args = args_cmd
|
||||
.next()
|
||||
.ok_or(ProcessError::InvalidArguments(umu_string.clone()))?
|
||||
.trim();
|
||||
let cmd = format!("umu-run{}", args_cmd.next().ok_or(ProcessError::InvalidArguments(umu_string.clone()))?);
|
||||
|
||||
format!("{args} muvm -- {cmd}")
|
||||
Ok(format!("{args} muvm -- {cmd}"))
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
@ -10,6 +10,7 @@ use std::{
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use database::{borrow_db_checked, borrow_db_mut_checked, db::DATA_ROOT_DIR, platform::Platform, ApplicationTransientStatus, Database, DownloadType, DownloadableMetadata, GameDownloadStatus, GameVersion};
|
||||
use dynfmt::Format;
|
||||
use dynfmt::SimpleCurlyFormat;
|
||||
use log::{debug, info, warn};
|
||||
@ -17,23 +18,10 @@ use serde::{Deserialize, Serialize};
|
||||
use shared_child::SharedChild;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use utils::lock;
|
||||
|
||||
use crate::{error::ProcessError, format::DropFormatArgs, process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher}};
|
||||
|
||||
use crate::{
|
||||
AppState, DB,
|
||||
database::{
|
||||
db::{DATA_ROOT_DIR, borrow_db_checked, borrow_db_mut_checked},
|
||||
models::data::{
|
||||
ApplicationTransientStatus, Database, DownloadType, DownloadableMetadata,
|
||||
GameDownloadStatus, GameVersion,
|
||||
},
|
||||
},
|
||||
error::process_error::ProcessError,
|
||||
games::{library::push_game_update, state::GameStatusManager},
|
||||
process::{
|
||||
format::DropFormatArgs,
|
||||
process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct RunningProcess {
|
||||
handle: Arc<SharedChild>,
|
||||
@ -118,7 +106,7 @@ impl ProcessManager<'_> {
|
||||
let dir = self.get_log_dir(game_id);
|
||||
self.app_handle
|
||||
.opener()
|
||||
.open_path(dir.to_str().unwrap(), None::<&str>)
|
||||
.open_path(dir.display().to_string(), None::<&str>)
|
||||
.map_err(ProcessError::OpenerError)?;
|
||||
Ok(())
|
||||
}
|
||||
@ -133,7 +121,13 @@ impl ProcessManager<'_> {
|
||||
|
||||
debug!("process for {:?} exited with {:?}", &game_id, result);
|
||||
|
||||
let process = self.processes.remove(&game_id).unwrap();
|
||||
let process = match self.processes.remove(&game_id) {
|
||||
Some(process) => process,
|
||||
None => {
|
||||
info!("Attempted to stop process {game_id} which didn't exist");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut db_handle = borrow_db_mut_checked();
|
||||
let meta = db_handle
|
||||
@ -141,7 +135,7 @@ impl ProcessManager<'_> {
|
||||
.installed_game_version
|
||||
.get(&game_id)
|
||||
.cloned()
|
||||
.unwrap();
|
||||
.unwrap_or_else(|| panic!("Could not get installed version of {}", &game_id));
|
||||
db_handle.applications.transient_statuses.remove(&meta);
|
||||
|
||||
let current_state = db_handle.applications.game_statuses.get(&game_id).cloned();
|
||||
@ -166,20 +160,17 @@ impl ProcessManager<'_> {
|
||||
// Or if the status isn't 0
|
||||
// Or if it's an error
|
||||
if !process.manually_killed
|
||||
&& (elapsed.as_secs() <= 2 || result.is_err() || !result.unwrap().success())
|
||||
&& (elapsed.as_secs() <= 2 || result.map_or(true, |r| !r.success()))
|
||||
{
|
||||
warn!("drop detected that the game {game_id} may have failed to launch properly");
|
||||
let _ = self.app_handle.emit("launch_external_error", &game_id);
|
||||
}
|
||||
|
||||
// This is too many unwraps for me to be comfortable
|
||||
let version_data = db_handle
|
||||
.applications
|
||||
.game_versions
|
||||
.get(&game_id)
|
||||
.unwrap()
|
||||
.get(&meta.version.unwrap())
|
||||
.unwrap();
|
||||
let version_data = match db_handle.applications.game_versions.get(&game_id) {
|
||||
// This unwrap here should be resolved by just making the hashmap accept an option rather than just a String
|
||||
Some(res) => res.get(&meta.version.unwrap()).expect("Failed to get game version from installed game versions. Is the database corrupted?"),
|
||||
None => todo!(),
|
||||
};
|
||||
|
||||
let status = GameStatusManager::fetch_state(&game_id, &db_handle);
|
||||
|
||||
@ -210,10 +201,10 @@ impl ProcessManager<'_> {
|
||||
.1)
|
||||
}
|
||||
|
||||
pub fn valid_platform(&self, platform: &Platform, state: &AppState) -> Result<bool, String> {
|
||||
pub fn valid_platform(&self, platform: &Platform, state: &AppState) -> bool {
|
||||
let db_lock = borrow_db_checked();
|
||||
let process_handler = self.fetch_process_handler(&db_lock, state, platform);
|
||||
Ok(process_handler.is_ok())
|
||||
process_handler.is_ok()
|
||||
}
|
||||
|
||||
pub fn launch_process(
|
||||
@ -225,9 +216,7 @@ impl ProcessManager<'_> {
|
||||
return Err(ProcessError::AlreadyRunning);
|
||||
}
|
||||
|
||||
let version = match DB
|
||||
.borrow_data()
|
||||
.unwrap()
|
||||
let version = match borrow_db_checked()
|
||||
.applications
|
||||
.game_statuses
|
||||
.get(&game_id)
|
||||
@ -266,7 +255,7 @@ impl ProcessManager<'_> {
|
||||
debug!(
|
||||
"Launching process {:?} with version {:?}",
|
||||
&game_id,
|
||||
db_lock.applications.game_versions.get(&game_id).unwrap()
|
||||
db_lock.applications.game_versions.get(&game_id)
|
||||
);
|
||||
|
||||
let game_version = db_lock
|
||||
@ -322,8 +311,9 @@ impl ProcessManager<'_> {
|
||||
GameDownloadStatus::Remote {} => unreachable!("Game registered as 'Remote'"),
|
||||
};
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let launch = PathBuf::from_str(install_dir).unwrap().join(launch);
|
||||
let launch = launch.to_str().unwrap();
|
||||
let launch = launch.display().to_string();
|
||||
|
||||
let launch_string = process_handler.create_launch_process(
|
||||
&meta,
|
||||
@ -331,7 +321,7 @@ impl ProcessManager<'_> {
|
||||
args.clone(),
|
||||
game_version,
|
||||
install_dir,
|
||||
);
|
||||
)?;
|
||||
|
||||
let format_args = DropFormatArgs::new(
|
||||
launch_string,
|
||||
@ -392,9 +382,12 @@ impl ProcessManager<'_> {
|
||||
let result: Result<ExitStatus, std::io::Error> = launch_process_handle.wait();
|
||||
|
||||
let app_state = wait_thread_apphandle.state::<Mutex<AppState>>();
|
||||
let app_state_handle = app_state.lock().unwrap();
|
||||
let app_state_handle = lock!(app_state);
|
||||
|
||||
let mut process_manager_handle = app_state_handle.process_manager.lock().unwrap();
|
||||
let mut process_manager_handle = app_state_handle
|
||||
.process_manager
|
||||
.lock()
|
||||
.expect("Failed to lock onto process manager");
|
||||
process_manager_handle.on_process_finish(wait_thread_game_id.id, result);
|
||||
|
||||
// As everything goes out of scope, they should get dropped
|
||||
@ -415,51 +408,6 @@ impl ProcessManager<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
pub enum Platform {
|
||||
Windows,
|
||||
Linux,
|
||||
MacOs,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const HOST: Platform = Self::Windows;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub const HOST: Platform = Self::MacOs;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub const HOST: Platform = Self::Linux;
|
||||
|
||||
pub fn is_case_sensitive(&self) -> bool {
|
||||
match self {
|
||||
Self::Windows | Self::MacOs => false,
|
||||
Self::Linux => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Platform {
|
||||
fn from(value: &str) -> Self {
|
||||
match value.to_lowercase().trim() {
|
||||
"windows" => Self::Windows,
|
||||
"linux" => Self::Linux,
|
||||
"mac" | "macos" => Self::MacOs,
|
||||
_ => unimplemented!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<whoami::Platform> for Platform {
|
||||
fn from(value: whoami::Platform) -> Self {
|
||||
match value {
|
||||
whoami::Platform::Windows => Platform::Windows,
|
||||
whoami::Platform::Linux => Platform::Linux,
|
||||
whoami::Platform::MacOS => Platform::MacOs,
|
||||
_ => unimplemented!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ProcessHandler: Send + 'static {
|
||||
fn create_launch_process(
|
||||
&self,
|
||||
@ -468,7 +416,7 @@ pub trait ProcessHandler: Send + 'static {
|
||||
args: Vec<String>,
|
||||
game_version: &GameVersion,
|
||||
current_dir: &str,
|
||||
) -> String;
|
||||
) -> Result<String, ProcessError>;
|
||||
|
||||
fn valid_for_platform(&self, db: &Database, state: &AppState, target: &Platform) -> bool;
|
||||
}
|
||||
23
src-tauri/remote/Cargo.toml
Normal file
@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "remote"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bitcode = "0.6.7"
|
||||
chrono = "0.4.42"
|
||||
client = { version = "0.1.0", path = "../client" }
|
||||
database = { version = "0.1.0", path = "../database" }
|
||||
droplet-rs = "0.7.3"
|
||||
gethostname = "1.0.2"
|
||||
hex = "0.4.3"
|
||||
http = "1.3.1"
|
||||
log = "0.4.28"
|
||||
md5 = "0.8.0"
|
||||
reqwest = "0.12.23"
|
||||
reqwest-websocket = "0.5.1"
|
||||
serde = "1.0.228"
|
||||
serde_with = "3.15.0"
|
||||
tauri = "2.8.5"
|
||||
url = "2.5.7"
|
||||
utils = { version = "0.1.0", path = "../utils" }
|
||||
136
src-tauri/remote/src/auth.rs
Normal file
@ -0,0 +1,136 @@
|
||||
use std::{collections::HashMap, env};
|
||||
|
||||
use chrono::Utc;
|
||||
use client::{app_status::AppStatus, user::User};
|
||||
use database::interface::borrow_db_checked;
|
||||
use droplet_rs::ssl::sign_nonce;
|
||||
use gethostname::gethostname;
|
||||
use log::{error, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::{error::{DropServerError, RemoteAccessError}, requests::make_authenticated_get, utils::DROP_CLIENT_SYNC};
|
||||
|
||||
use super::{
|
||||
cache::{cache_object, get_cached_object},
|
||||
requests::generate_url,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CapabilityConfiguration {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InitiateRequestBody {
|
||||
name: String,
|
||||
platform: String,
|
||||
capabilities: HashMap<String, CapabilityConfiguration>,
|
||||
mode: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HandshakeRequestBody {
|
||||
client_id: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HandshakeResponse {
|
||||
private: String,
|
||||
certificate: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
pub fn generate_authorization_header() -> String {
|
||||
let certs = {
|
||||
let db = borrow_db_checked();
|
||||
db.auth.clone().expect("Authorisation not initialised")
|
||||
};
|
||||
|
||||
let nonce = Utc::now().timestamp_millis().to_string();
|
||||
|
||||
let signature =
|
||||
sign_nonce(certs.private, nonce.clone()).expect("Failed to generate authorisation header");
|
||||
|
||||
format!("Nonce {} {} {}", certs.client_id, nonce, signature)
|
||||
}
|
||||
|
||||
pub async fn fetch_user() -> Result<User, RemoteAccessError> {
|
||||
let response = make_authenticated_get(generate_url(&["/api/v1/client/user"], &[])?).await?;
|
||||
if response.status() != 200 {
|
||||
let err: DropServerError = response.json().await?;
|
||||
warn!("{err:?}");
|
||||
|
||||
if err.status_message == "Nonce expired" {
|
||||
return Err(RemoteAccessError::OutOfSync);
|
||||
}
|
||||
|
||||
return Err(RemoteAccessError::InvalidResponse(err));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<User>()
|
||||
.await
|
||||
.map_err(std::convert::Into::into)
|
||||
}
|
||||
|
||||
pub fn auth_initiate_logic(mode: String) -> Result<String, RemoteAccessError> {
|
||||
let base_url = {
|
||||
let db_lock = borrow_db_checked();
|
||||
Url::parse(&db_lock.base_url.clone())?
|
||||
};
|
||||
|
||||
let hostname = gethostname();
|
||||
|
||||
let endpoint = base_url.join("/api/v1/client/auth/initiate")?;
|
||||
let body = InitiateRequestBody {
|
||||
name: format!("{} (Desktop)", hostname.display()),
|
||||
platform: env::consts::OS.to_string(),
|
||||
capabilities: HashMap::from([
|
||||
("peerAPI".to_owned(), CapabilityConfiguration {}),
|
||||
("cloudSaves".to_owned(), CapabilityConfiguration {}),
|
||||
]),
|
||||
mode,
|
||||
};
|
||||
|
||||
let client = DROP_CLIENT_SYNC.clone();
|
||||
let response = client.post(endpoint.to_string()).json(&body).send()?;
|
||||
|
||||
if response.status() != 200 {
|
||||
let data: DropServerError = response.json()?;
|
||||
error!("could not start handshake: {}", data.status_message);
|
||||
|
||||
return Err(RemoteAccessError::HandshakeFailed(data.status_message));
|
||||
}
|
||||
|
||||
let response = response.text()?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn setup() -> (AppStatus, Option<User>) {
|
||||
let auth = {
|
||||
let data = borrow_db_checked();
|
||||
data.auth.clone()
|
||||
};
|
||||
|
||||
if auth.is_some() {
|
||||
let user_result = match fetch_user().await {
|
||||
Ok(data) => data,
|
||||
Err(RemoteAccessError::FetchError(_)) => {
|
||||
let user = get_cached_object::<User>("user").ok();
|
||||
return (AppStatus::Offline, user);
|
||||
}
|
||||
Err(_) => return (AppStatus::SignedInNeedsReauth, None),
|
||||
};
|
||||
if let Err(e) = cache_object("user", &user_result) {
|
||||
warn!("Could not cache user object with error {e}");
|
||||
}
|
||||
return (AppStatus::SignedIn, Some(user_result));
|
||||
}
|
||||
|
||||
(AppStatus::SignedOut, None)
|
||||
}
|
||||
@ -5,18 +5,17 @@ use std::{
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
database::{db::borrow_db_checked, models::data::Database},
|
||||
error::remote_access_error::RemoteAccessError,
|
||||
};
|
||||
use bitcode::{Decode, DecodeOwned, Encode};
|
||||
use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder};
|
||||
use database::{borrow_db_checked, Database};
|
||||
use http::{header::{CONTENT_TYPE}, response::Builder as ResponseBuilder, Response};
|
||||
|
||||
use crate::error::{CacheError, RemoteAccessError};
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! offline {
|
||||
($var:expr, $func1:expr, $func2:expr, $( $arg:expr ),* ) => {
|
||||
|
||||
async move { if $crate::borrow_db_checked().settings.force_offline || $var.lock().unwrap().status == $crate::AppStatus::Offline {
|
||||
async move { if $crate::borrow_db_checked().settings.force_offline || $crate::lock!($var).status == $crate::AppStatus::Offline {
|
||||
$func2( $( $arg ), *).await
|
||||
} else {
|
||||
$func1( $( $arg ), *).await
|
||||
@ -50,6 +49,12 @@ fn read_sync(base: &Path, key: &str) -> io::Result<Vec<u8>> {
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn delete_sync(base: &Path, key: &str) -> io::Result<()> {
|
||||
let cache_path = get_cache_path(base, key);
|
||||
std::fs::remove_file(cache_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cache_object<D: Encode>(key: &str, data: &D) -> Result<(), RemoteAccessError> {
|
||||
cache_object_db(key, data, &borrow_db_checked())
|
||||
}
|
||||
@ -73,6 +78,17 @@ pub fn get_cached_object_db<D: DecodeOwned>(
|
||||
bitcode::decode::<D>(&bytes).map_err(|e| RemoteAccessError::Cache(io::Error::other(e)))?;
|
||||
Ok(data)
|
||||
}
|
||||
pub fn clear_cached_object(key: &str) -> Result<(), RemoteAccessError> {
|
||||
clear_cached_object_db(key, &borrow_db_checked())
|
||||
}
|
||||
pub fn clear_cached_object_db(
|
||||
key: &str,
|
||||
db: &Database,
|
||||
) -> Result<(), RemoteAccessError> {
|
||||
delete_sync(&db.cache_dir, key).map_err(RemoteAccessError::Cache)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Encode, Decode)]
|
||||
pub struct ObjectCache {
|
||||
content_type: String,
|
||||
@ -87,30 +103,36 @@ impl ObjectCache {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Response<Vec<u8>>> for ObjectCache {
|
||||
fn from(value: Response<Vec<u8>>) -> Self {
|
||||
ObjectCache {
|
||||
impl TryFrom<Response<Vec<u8>>> for ObjectCache {
|
||||
type Error = CacheError;
|
||||
|
||||
fn try_from(value: Response<Vec<u8>>) -> Result<Self, Self::Error> {
|
||||
Ok(ObjectCache {
|
||||
content_type: value
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.unwrap()
|
||||
.ok_or(CacheError::HeaderNotFound(CONTENT_TYPE))?
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.map_err(CacheError::ParseError)?
|
||||
.to_owned(),
|
||||
body: value.body().clone(),
|
||||
expiry: get_sys_time_in_secs() + 60 * 60 * 24,
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
impl From<ObjectCache> for Response<Vec<u8>> {
|
||||
fn from(value: ObjectCache) -> Self {
|
||||
impl TryFrom<ObjectCache> for Response<Vec<u8>> {
|
||||
type Error = CacheError;
|
||||
fn try_from(value: ObjectCache) -> Result<Self, Self::Error> {
|
||||
let resp_builder = ResponseBuilder::new().header(CONTENT_TYPE, value.content_type);
|
||||
resp_builder.body(value.body).unwrap()
|
||||
resp_builder.body(value.body).map_err(CacheError::ConstructionError)
|
||||
}
|
||||
}
|
||||
impl From<&ObjectCache> for Response<Vec<u8>> {
|
||||
fn from(value: &ObjectCache) -> Self {
|
||||
impl TryFrom<&ObjectCache> for Response<Vec<u8>> {
|
||||
type Error = CacheError;
|
||||
|
||||
fn try_from(value: &ObjectCache) -> Result<Self, Self::Error> {
|
||||
let resp_builder = ResponseBuilder::new().header(CONTENT_TYPE, value.content_type.clone());
|
||||
resp_builder.body(value.body.clone()).unwrap()
|
||||
resp_builder.body(value.body.clone()).map_err(CacheError::ConstructionError)
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,21 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use http::StatusCode;
|
||||
use http::{header::ToStrError, HeaderName, StatusCode};
|
||||
use serde_with::SerializeDisplay;
|
||||
use url::ParseError;
|
||||
|
||||
use super::drop_server_error::DropServerError;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DropServerError {
|
||||
pub status_code: usize,
|
||||
pub status_message: String,
|
||||
// pub message: String,
|
||||
// pub url: String,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, SerializeDisplay)]
|
||||
pub enum RemoteAccessError {
|
||||
@ -44,8 +54,7 @@ impl Display for RemoteAccessError {
|
||||
error
|
||||
.source()
|
||||
.map(std::string::ToString::to_string)
|
||||
.or_else(|| Some("Unknown error".to_string()))
|
||||
.unwrap()
|
||||
.unwrap_or("Unknown error".to_string())
|
||||
)
|
||||
}
|
||||
RemoteAccessError::FetchErrorWS(error) => write!(
|
||||
@ -54,9 +63,8 @@ impl Display for RemoteAccessError {
|
||||
error,
|
||||
error
|
||||
.source()
|
||||
.map(|e| e.to_string())
|
||||
.or_else(|| Some("Unknown error".to_string()))
|
||||
.unwrap()
|
||||
.map(std::string::ToString::to_string)
|
||||
.unwrap_or("Unknown error".to_string())
|
||||
),
|
||||
RemoteAccessError::ParsingError(parse_error) => {
|
||||
write!(f, "{parse_error}")
|
||||
@ -106,3 +114,23 @@ impl From<ParseError> for RemoteAccessError {
|
||||
}
|
||||
}
|
||||
impl std::error::Error for RemoteAccessError {}
|
||||
|
||||
#[derive(Debug, SerializeDisplay)]
|
||||
pub enum CacheError {
|
||||
HeaderNotFound(HeaderName),
|
||||
ParseError(ToStrError),
|
||||
Remote(RemoteAccessError),
|
||||
ConstructionError(http::Error)
|
||||
}
|
||||
|
||||
impl Display for CacheError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
CacheError::HeaderNotFound(header_name) => format!("Could not find header {header_name} in cache"),
|
||||
CacheError::ParseError(to_str_error) => format!("Could not parse cache with error {to_str_error}"),
|
||||
CacheError::Remote(remote_access_error) => format!("Cache got remote access error: {remote_access_error}"),
|
||||
CacheError::ConstructionError(error) => format!("Could not construct cache body with error {error}"),
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
76
src-tauri/remote/src/fetch_object.rs
Normal file
@ -0,0 +1,76 @@
|
||||
use database::{interface::DatabaseImpls, DB};
|
||||
use http::{header::CONTENT_TYPE, response::Builder as ResponseBuilder, Response};
|
||||
use log::{debug, warn};
|
||||
use tauri::UriSchemeResponder;
|
||||
|
||||
use crate::{error::CacheError, utils::DROP_CLIENT_ASYNC};
|
||||
|
||||
use super::{
|
||||
auth::generate_authorization_header,
|
||||
cache::{ObjectCache, cache_object, get_cached_object},
|
||||
};
|
||||
|
||||
pub async fn fetch_object_wrapper(request: http::Request<Vec<u8>>, responder: UriSchemeResponder) {
|
||||
match fetch_object(request).await {
|
||||
Ok(r) => responder.respond(r),
|
||||
Err(e) => {
|
||||
warn!("Cache error: {e}");
|
||||
responder.respond(Response::builder().status(500).body(Vec::new()).expect("Failed to build error response"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn fetch_object(request: http::Request<Vec<u8>>) -> Result<Response<Vec<u8>>, CacheError>
|
||||
{
|
||||
// Drop leading /
|
||||
let object_id = &request.uri().path()[1..];
|
||||
|
||||
let cache_result = get_cached_object::<ObjectCache>(object_id);
|
||||
if let Ok(cache_result) = &cache_result
|
||||
&& !cache_result.has_expired()
|
||||
{
|
||||
return cache_result.try_into();
|
||||
}
|
||||
|
||||
let header = generate_authorization_header();
|
||||
let client = DROP_CLIENT_ASYNC.clone();
|
||||
let url = format!("{}api/v1/client/object/{object_id}", DB.fetch_base_url());
|
||||
let response = client.get(url).header("Authorization", header).send().await;
|
||||
|
||||
match response {
|
||||
Ok(r) => {
|
||||
let resp_builder = ResponseBuilder::new().header(
|
||||
CONTENT_TYPE,
|
||||
r.headers()
|
||||
.get("Content-Type")
|
||||
.expect("Failed get Content-Type header"),
|
||||
);
|
||||
let data = match r.bytes().await {
|
||||
Ok(data) => Vec::from(data),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Could not get data from cache object {object_id} with error {e}",
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let resp = resp_builder.body(data).expect("Failed to build object cache response body");
|
||||
if cache_result.map_or(true, |x| x.has_expired()) {
|
||||
cache_object::<ObjectCache>(object_id, &resp.clone().try_into()?)
|
||||
.expect("Failed to create cached object");
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Object fetch failed with error {e}. Attempting to download from cache");
|
||||
match cache_result {
|
||||
Ok(cache_result) => cache_result.try_into(),
|
||||
Err(e) => {
|
||||
warn!("{e}");
|
||||
Err(CacheError::Remote(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
pub mod auth;
|
||||
#[macro_use]
|
||||
pub mod cache;
|
||||
pub mod commands;
|
||||
pub mod fetch_object;
|
||||
pub mod requests;
|
||||
pub mod server_proto;
|
||||
pub mod utils;
|
||||
pub mod error;
|
||||
|
||||
pub use auth::setup;
|
||||
@ -1,10 +1,8 @@
|
||||
use database::{interface::DatabaseImpls, DB};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
DB,
|
||||
database::db::DatabaseImpls,
|
||||
error::remote_access_error::RemoteAccessError,
|
||||
remote::{auth::generate_authorization_header, utils::DROP_CLIENT_ASYNC},
|
||||
auth::generate_authorization_header, error::RemoteAccessError, utils::DROP_CLIENT_ASYNC,
|
||||
};
|
||||
|
||||
pub fn generate_url<T: AsRef<str>>(
|
||||
93
src-tauri/remote/src/server_proto.rs
Normal file
@ -0,0 +1,93 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use database::borrow_db_checked;
|
||||
use http::{uri::PathAndQuery, Request, Response, StatusCode, Uri};
|
||||
use log::{error, warn};
|
||||
use tauri::UriSchemeResponder;
|
||||
use utils::webbrowser_open::webbrowser_open;
|
||||
|
||||
use crate::utils::DROP_CLIENT_SYNC;
|
||||
|
||||
pub async fn handle_server_proto_offline_wrapper(request: Request<Vec<u8>>, responder: UriSchemeResponder) {
|
||||
responder.respond(match handle_server_proto_offline(request).await {
|
||||
Ok(res) => res,
|
||||
Err(_) => unreachable!()
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn handle_server_proto_offline(_request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>, StatusCode>{
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Vec::new())
|
||||
.expect("Failed to build error response for proto offline"))
|
||||
|
||||
}
|
||||
|
||||
pub async fn handle_server_proto_wrapper(request: Request<Vec<u8>>, responder: UriSchemeResponder) {
|
||||
match handle_server_proto(request).await {
|
||||
Ok(r) => responder.respond(r),
|
||||
Err(e) => {
|
||||
warn!("Cache error: {e}");
|
||||
responder.respond(Response::builder().status(e).body(Vec::new()).expect("Failed to build error response"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_server_proto(request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>, StatusCode> {
|
||||
let db_handle = borrow_db_checked();
|
||||
let auth = match db_handle.auth.as_ref() {
|
||||
Some(auth) => auth,
|
||||
None => {
|
||||
error!("Could not find auth in database");
|
||||
return Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
};
|
||||
let web_token = match &auth.web_token {
|
||||
Some(token) => token,
|
||||
None => return Err(StatusCode::UNAUTHORIZED),
|
||||
};
|
||||
let remote_uri = db_handle.base_url.parse::<Uri>().expect("Failed to parse base url");
|
||||
|
||||
let path = request.uri().path();
|
||||
|
||||
let mut new_uri = request.uri().clone().into_parts();
|
||||
new_uri.path_and_query =
|
||||
Some(PathAndQuery::from_str(&format!("{path}?noWrapper=true")).expect("Failed to parse request path in proto"));
|
||||
new_uri.authority = remote_uri.authority().cloned();
|
||||
new_uri.scheme = remote_uri.scheme().cloned();
|
||||
let err_msg = &format!("Failed to build new uri from parts {new_uri:?}");
|
||||
let new_uri = Uri::from_parts(new_uri).expect(err_msg);
|
||||
|
||||
let whitelist_prefix = ["/store", "/api", "/_", "/fonts"];
|
||||
|
||||
if whitelist_prefix.iter().all(|f| !path.starts_with(f)) {
|
||||
webbrowser_open(new_uri.to_string());
|
||||
return Ok(Response::new(Vec::new()))
|
||||
}
|
||||
|
||||
let client = DROP_CLIENT_SYNC.clone();
|
||||
let response = match client
|
||||
.request(request.method().clone(), new_uri.to_string())
|
||||
.header("Authorization", format!("Bearer {web_token}"))
|
||||
.headers(request.headers().clone())
|
||||
.send() {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
warn!("Could not send response. Got {e} when sending");
|
||||
return Err(e.status().unwrap_or(StatusCode::BAD_REQUEST))
|
||||
},
|
||||
};
|
||||
|
||||
let response_status = response.status();
|
||||
let response_body = match response.bytes() {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => return Err(e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)),
|
||||
};
|
||||
|
||||
let http_response = Response::builder()
|
||||
.status(response_status)
|
||||
.body(response_body.to_vec())
|
||||
.expect("Failed to build server proto response");
|
||||
|
||||
Ok(http_response)
|
||||
}
|
||||
107
src-tauri/remote/src/utils.rs
Normal file
@ -0,0 +1,107 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::Read,
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use database::db::DATA_ROOT_DIR;
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::Certificate;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DropHealthcheck {
|
||||
app_name: String,
|
||||
}
|
||||
|
||||
static DROP_CERT_BUNDLE: LazyLock<Vec<Certificate>> = LazyLock::new(fetch_certificates);
|
||||
pub static DROP_CLIENT_SYNC: LazyLock<reqwest::blocking::Client> = LazyLock::new(get_client_sync);
|
||||
pub static DROP_CLIENT_ASYNC: LazyLock<reqwest::Client> = LazyLock::new(get_client_async);
|
||||
pub static DROP_CLIENT_WS_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(get_client_ws);
|
||||
|
||||
fn fetch_certificates() -> Vec<Certificate> {
|
||||
let certificate_dir = DATA_ROOT_DIR.join("certificates");
|
||||
|
||||
let mut certs = Vec::new();
|
||||
match fs::read_dir(certificate_dir) {
|
||||
Ok(c) => {
|
||||
for entry in c {
|
||||
match entry {
|
||||
Ok(c) => {
|
||||
let mut buf = Vec::new();
|
||||
match File::open(c.path()) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to open file at {} with error {}",
|
||||
c.path().display(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
.read_to_end(&mut buf)
|
||||
.unwrap_or_else(|e| panic!(
|
||||
"Failed to read to end of certificate file {} with error {}",
|
||||
c.path().display(),
|
||||
e
|
||||
));
|
||||
|
||||
match Certificate::from_pem_bundle(&buf) {
|
||||
Ok(certificates) => {
|
||||
for cert in certificates {
|
||||
certs.push(cert);
|
||||
}
|
||||
info!(
|
||||
"added {} certificate(s) from {}",
|
||||
certs.len(),
|
||||
c.file_name().display()
|
||||
);
|
||||
}
|
||||
Err(e) => warn!(
|
||||
"Invalid certificate file {} with error {}",
|
||||
c.path().display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("not loading certificates due to error: {e}");
|
||||
}
|
||||
};
|
||||
certs
|
||||
}
|
||||
|
||||
pub fn get_client_sync() -> reqwest::blocking::Client {
|
||||
let mut client = reqwest::blocking::ClientBuilder::new();
|
||||
|
||||
for cert in DROP_CERT_BUNDLE.iter() {
|
||||
client = client.add_root_certificate(cert.clone());
|
||||
}
|
||||
client.use_rustls_tls().build().expect("Failed to build synchronous client")
|
||||
}
|
||||
pub fn get_client_async() -> reqwest::Client {
|
||||
let mut client = reqwest::ClientBuilder::new();
|
||||
|
||||
for cert in DROP_CERT_BUNDLE.iter() {
|
||||
client = client.add_root_certificate(cert.clone());
|
||||
}
|
||||
client.use_rustls_tls().build().expect("Failed to build asynchronous client")
|
||||
}
|
||||
pub fn get_client_ws() -> reqwest::Client {
|
||||
let mut client = reqwest::ClientBuilder::new();
|
||||
|
||||
for cert in DROP_CERT_BUNDLE.iter() {
|
||||
client = client.add_root_certificate(cert.clone());
|
||||
}
|
||||
client
|
||||
.use_rustls_tls()
|
||||
.http1_only()
|
||||
.build()
|
||||
.expect("Failed to build websocket client")
|
||||
}
|
||||
7741
src-tauri/src-tauri/Cargo.lock
generated
Normal file
137
src-tauri/src-tauri/Cargo.toml
Normal file
@ -0,0 +1,137 @@
|
||||
[package]
|
||||
name = "drop-app"
|
||||
version = "0.3.3"
|
||||
description = "The client application for the open-source, self-hosted game distribution platform Drop"
|
||||
authors = ["Drop OSS"]
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "drop_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
rustflags = ["-C", "target-feature=+aes,+sse2"]
|
||||
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri-plugin-shell = "2.2.1"
|
||||
serde_json = "1"
|
||||
rayon = "1.10.0"
|
||||
webbrowser = "1.0.2"
|
||||
url = "2.5.2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
log = "0.4.22"
|
||||
hex = "0.4.3"
|
||||
tauri-plugin-dialog = "2"
|
||||
http = "1.1.0"
|
||||
urlencoding = "2.1.3"
|
||||
md5 = "0.7.0"
|
||||
chrono = "0.4.38"
|
||||
tauri-plugin-os = "2"
|
||||
boxcar = "0.2.7"
|
||||
umu-wrapper-lib = "0.1.0"
|
||||
tauri-plugin-autostart = "2.0.0"
|
||||
shared_child = "1.0.1"
|
||||
serde_with = "3.12.0"
|
||||
slice-deque = "0.3.0"
|
||||
throttle_my_fn = "0.2.6"
|
||||
parking_lot = "0.12.3"
|
||||
atomic-instant-full = "0.1.0"
|
||||
cacache = "13.1.0"
|
||||
http-serde = "2.1.1"
|
||||
reqwest-middleware = "0.4.0"
|
||||
reqwest-middleware-cache = "0.1.1"
|
||||
deranged = "=0.4.0"
|
||||
droplet-rs = "0.7.3"
|
||||
gethostname = "1.0.1"
|
||||
zstd = "0.13.3"
|
||||
tar = "0.4.44"
|
||||
rand = "0.9.1"
|
||||
regex = "1.11.1"
|
||||
tempfile = "3.19.1"
|
||||
schemars = "0.8.22"
|
||||
sha1 = "0.10.6"
|
||||
dirs = "6.0.0"
|
||||
whoami = "1.6.0"
|
||||
filetime = "0.2.25"
|
||||
walkdir = "2.5.0"
|
||||
known-folders = "1.2.0"
|
||||
native_model = { version = "0.6.4", features = ["rmp_serde_1_3"], git = "https://github.com/Drop-OSS/native_model.git"}
|
||||
tauri-plugin-opener = "2.4.0"
|
||||
bitcode = "0.6.6"
|
||||
reqwest-websocket = "0.5.0"
|
||||
futures-lite = "2.6.0"
|
||||
page_size = "0.6.0"
|
||||
sysinfo = "0.36.1"
|
||||
humansize = "2.1.3"
|
||||
tokio-util = { version = "0.7.16", features = ["io"] }
|
||||
futures-core = "0.3.31"
|
||||
bytes = "1.10.1"
|
||||
# tailscale = { path = "./tailscale" }
|
||||
|
||||
|
||||
# Workspaces
|
||||
client = { path = "../client" }
|
||||
database = { path = "../database" }
|
||||
process = { path = "../process" }
|
||||
remote = { path = "../remote" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
[dependencies.dynfmt]
|
||||
version = "0.1.5"
|
||||
features = ["curly"]
|
||||
|
||||
[dependencies.tauri]
|
||||
version = "2.7.0"
|
||||
features = ["protocol-asset", "tray-icon"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.40.0"
|
||||
features = ["rt", "tokio-macros", "signal"]
|
||||
|
||||
[dependencies.log4rs]
|
||||
version = "1.3.0"
|
||||
features = ["console_appender", "file_appender"]
|
||||
|
||||
[dependencies.rustix]
|
||||
version = "0.38.37"
|
||||
features = ["fs"]
|
||||
|
||||
[dependencies.uuid]
|
||||
version = "1.10.0"
|
||||
features = ["v4", "fast-rng", "macro-diagnostics"]
|
||||
|
||||
[dependencies.rustbreak]
|
||||
version = "2"
|
||||
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.12.22"
|
||||
default-features = false
|
||||
features = [
|
||||
"json",
|
||||
"http2",
|
||||
"blocking",
|
||||
"rustls-tls",
|
||||
"native-tls-alpn",
|
||||
"rustls-tls-native-roots",
|
||||
"stream",
|
||||
]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1"
|
||||
features = ["derive", "rc"]
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = 'abort'
|
||||
1
src-tauri/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/src-tauri/gen/schemas/capabilities.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
3037
src-tauri/src-tauri/gen/schemas/desktop-schema.json
Normal file
3037
src-tauri/src-tauri/gen/schemas/linux-schema.json
Normal file
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 911 B After Width: | Height: | Size: 911 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 803 B After Width: | Height: | Size: 803 B |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |