Compare commits

..

1 Commits

Author SHA1 Message Date
346ee1dddc chore: Added libayatana-appindicator3.so.1 to appimage files
Signed-off-by: quexeky <git@quexeky.dev>
2025-08-09 10:46:24 +10:00
191 changed files with 4438 additions and 31827 deletions

View File

@ -51,7 +51,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
@ -69,9 +69,9 @@ jobs:
security set-keychain-settings -t 3600 -u build.keychain
curl https://droposs.org/drop.crt --output drop.pem
sudo security authorizationdb write com.apple.trust-settings.user allow
security add-trusted-cert -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem
sudo security authorizationdb remove com.apple.trust-settings.user
sudo security authorizationdb write com.apple.trust-settings.admin allow
sudo security add-trusted-cert -d -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem
sudo security authorizationdb remove com.apple.trust-settings.admin
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain

4
.gitignore vendored
View File

@ -29,6 +29,4 @@ src-tauri/flamegraph.svg
src-tauri/perf*
/*.AppImage
/squashfs-root
/target/
/squashfs-root

View File

@ -1,21 +1,29 @@
# Drop Desktop Client
# Drop App
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.
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.
## Internals
## 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)
It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
## 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
## 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).
Then, install dependencies with `yarn`. This'll install the custom builder's dependencies. Then, check everything works properly with `yarn tauri build`.
Install dependencies with `yarn`
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 out the contributing guide on our Developer Docs: [Drop Developer Docs - Contributing](https://developer.droposs.org/contributing).
Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines.

View File

@ -21,13 +21,6 @@ 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);

View File

@ -1,5 +1,5 @@
<template>
<NuxtLoadingIndicator color="#2563eb" />
<LoadingIndicator />
<NuxtLayout class="select-none w-screen h-screen">
<NuxtPage />
<ModalStack />
@ -7,7 +7,14 @@
</template>
<script setup lang="ts">
import "~/composables/downloads.js";
import { invoke } from "@tauri-apps/api/core";
import { useAppState } from "./composables/app-state.js";
import {
initialNavigation,
setupHooks,
} from "./composables/state-navigation.js";
const router = useRouter();

View File

@ -2,7 +2,9 @@
<div class="h-16 bg-zinc-950 flex flex-row justify-between">
<div class="flex flex-row grow items-center pl-5 pr-2 py-3">
<div class="inline-flex items-center gap-x-10">
<Wordmark class="h-8 mb-0.5" />
<NuxtLink to="/store">
<Wordmark class="h-8 mb-0.5" />
</NuxtLink>
<nav class="inline-flex items-center mt-0.5">
<ol class="inline-flex items-center gap-x-6">
<NuxtLink
@ -40,7 +42,7 @@
</ol>
</div>
</div>
<WindowControl />
<WindowControl />
</div>
</template>

View File

@ -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-if="state.user.admin" v-slot="{ active }">
<MenuItem v-slot="{ active }">
<a
:href="adminUrl"
target="_blank"
@ -76,6 +76,7 @@ import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
import type { NavigationItem } from "../types";
import HeaderWidget from "./HeaderWidget.vue";
import { useAppState } from "~/composables/app-state";
import { invoke } from "@tauri-apps/api/core";
const open = ref(false);

View File

@ -1,5 +1,5 @@
<template>
<div class="flex flex-col h-full">
<div>
<div class="mb-3 inline-flex gap-x-2">
<div
class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
@ -20,7 +20,7 @@
/>
</div>
<button
@click="() => calculateGames(true, true)"
@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"
>
<ArrowPathIcon class="size-4" />
@ -28,125 +28,64 @@
</div>
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
<Disclosure
as="div"
v-for="(nav, navIndex) in filteredNavigation"
<NuxtLink
v-for="nav in filteredNavigation"
:key="nav.id"
class="first:pt-0 last:pb-0"
v-slot="{ open }"
:default-open="nav.deft"
: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',
nav.index === 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"
>
<dt>
<DisclosureButton
class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
<div class="flex items-center w-full gap-x-3">
<div
class="flex-none transition-transform duration-300 hover:-rotate-2"
>
<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="flex flex-col 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>
<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>
</TransitionGroup>
<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"
>
<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"
/>
<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"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
import {
ArrowPathIcon,
MagnifyingGlassIcon,
MinusSmallIcon,
PlusSmallIcon,
} from "@heroicons/vue/20/solid";
import { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import {
GameStatusEnum,
type Collection as Collection,
type Game,
type GameStatus,
} from "~/types";
import { GameStatusEnum, type Game, type GameStatus } from "~/types";
import { TransitionGroup } from "vue";
import { listen } from "@tauri-apps/api/event";
// Style information
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "text-green-500",
[GameStatusEnum.Downloading]: "text-zinc-400",
[GameStatusEnum.Downloading]: "text-blue-500",
[GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-green-500",
[GameStatusEnum.Remote]: "text-zinc-700",
[GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-zinc-400",
[GameStatusEnum.Remote]: "text-zinc-500",
[GameStatusEnum.Queued]: "text-blue-500",
[GameStatusEnum.Updating]: "text-blue-500",
[GameStatusEnum.Uninstalling]: "text-zinc-100",
[GameStatusEnum.SetupRequired]: "text-yellow-500",
[GameStatusEnum.PartiallyInstalled]: "text-gray-400",
@ -168,117 +107,71 @@ const router = useRouter();
const searchQuery = ref("");
const loading = ref(false);
const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {};
const icons: { [key: string]: string } = {};
const collections: Ref<Collection[]> = ref([]);
const rawGames: Ref<Game[], Game[]> = ref([]);
async function calculateGames(clearAll = false, forceRefresh = false) {
if (clearAll) {
collections.value = [];
loading.value = true;
}
async function calculateGames(clearAll = false) {
if (clearAll) rawGames.value = [];
// If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out
const newGames = await invoke<Game[]>("fetch_library", {
hardRefresh: forceRefresh,
});
const otherCollections = await invoke<Collection[]>("fetch_collections", {
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) {
const newGames = await invoke<typeof rawGames.value>("fetch_library");
for (const game of newGames) {
if (games[game.id]) continue;
games[game.id] = await useGame(game.id);
}
for (const game of allGames) {
for (const game of newGames) {
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;
collections.value = [libraryCollection, ...otherCollections];
rawGames.value = newGames;
}
// Wait up to 300 ms for the library to load, otherwise
// show the loading state while we while
await new Promise<void>((r) => {
let hasResolved = false;
const resolveFunc = () => {
if (!hasResolved) r();
hasResolved = true;
};
calculateGames(true).then(resolveFunc);
setTimeout(resolveFunc, 300);
});
await calculateGames();
const navigation = computed(() =>
collections.value.map((collection) => {
const items = collection.entries.map(({ game }) => {
const status = games[game.id].status;
rawGames.value.map((game) => {
const status = games[game.id].status;
const isInstalled = computed(
() => status.value.type != GameStatusEnum.Remote
);
const isInstalled = computed(
() =>
status.value.type == GameStatusEnum.Installed ||
status.value.type == GameStatusEnum.SetupRequired
);
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,
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
isInstalled,
id: game.id,
};
return item;
})
);
const route = useRoute();
const currentNavigation = computed(() => {
return route.path.slice("/library/".length);
});
const { currentNavigation, recalculateNavigation } = useCurrentNavigationIndex(
navigation.value
);
const filteredNavigation = computed(() => {
if (!searchQuery.value)
return navigation.value.map((e, i) => ({ ...e, index: i }));
const query = searchQuery.value.toLowerCase();
return navigation.value
.map((c) => ({
...c,
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
}))
.filter((e) => e.items.length > 0);
.filter((nav) => nav.label.toLowerCase().includes(query))
.map((e, i) => ({ ...e, index: i }));
});
listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = currentNavigation.value;
let oldNavigation = navigation.value[currentNavigation.value];
await calculateGames();
if (oldNavigation !== currentNavigation.value) {
recalculateNavigation();
if (oldNavigation !== navigation.value[currentNavigation.value]) {
console.log("Triggered");
router.push("/library");
}
});

View File

@ -0,0 +1,7 @@
<template></template>
<script setup lang="ts">
const loading = useLoadingIndicator();
watch(loading.isLoading, console.log);
</script>

View File

@ -0,0 +1,7 @@
<template>
<NuxtLink
class="inline-flex items-center gap-x-2 px-1 py-0.5 rounded bg-blue-900 text-zinc-100 hover:bg-blue-800"
>
<slot />
</NuxtLink>
</template>

View File

@ -32,5 +32,3 @@ listen("update_stats", (event) => {
const stats = useStatsState();
stats.value = event.payload as StatsState;
});
export const useDownloadHistory = () => useState<Array<number>>('history', () => []);

View File

@ -43,7 +43,6 @@ export const useGame = async (gameId: string) => {
gameStatusRegistry[gameId] = ref(parseStatus(data.status));
listen(`update_game/${gameId}`, (event) => {
console.log(event);
const payload: {
status: SerializedGameStatus;
version?: GameVersion;

View File

@ -1,5 +1,5 @@
import { convertFileSrc } from "@tauri-apps/api/core";
export const useObject = (id: string) => {
export const useObject = async (id: string) => {
return convertFileSrc(id, "object");
};

View File

@ -9,17 +9,13 @@ export default defineNuxtConfig({
},
},
css: ["~/assets/main.scss"],
ssr: false,
extends: ["../shared", "../libs/drop-base"],
extends: [["../libs/drop-base"]],
app: {
baseURL: "/main",
},
devtools: {
enabled: false,
},
}
});

View File

@ -1,7 +1,7 @@
{
"name": "view",
"private": true,
"version": "0.3.3",
"version": "0.3.1",
"type": "module",
"scripts": {
"build": "nuxt generate",

View File

@ -1,25 +0,0 @@
<template>
<div class="grow w-full h-full flex items-center justify-center">
<div class="flex flex-col items-center">
<WrenchScrewdriverIcon
class="h-12 w-12 text-blue-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Under construction
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-lg">
This page hasn't been implemented yet.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
WrenchScrewdriverIcon,
} from "@heroicons/vue/20/solid";
</script>

View File

@ -243,10 +243,7 @@
</div>
</Listbox>
</div>
<div
v-else-if="versionOptions === null || versionOptions?.length == 0"
class="mt-1 rounded-md bg-red-600/10 p-4"
>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
@ -259,27 +256,6 @@
</div>
</div>
</div>
<div v-else class="w-full flex items-center justify-center p-4">
<div role="status">
<svg
aria-hidden="true"
class="w-7 h-7 text-transparent animate-spin fill-white"
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"
/>
<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"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-if="installDirs">
<Listbox as="div" v-model="installDir">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
@ -534,13 +510,13 @@ async function installFlow() {
installDirs.value = undefined;
try {
versionOptions.value = await invoke("fetch_game_version_options", {
versionOptions.value = await invoke("fetch_game_verion_options", {
gameId: game.value.id,
});
console.log(versionOptions.value);
installDirs.value = await invoke("fetch_download_dir_stats");
} catch (error) {
installError.value = (error as string).toString();
versionOptions.value = undefined;
}
}

View File

@ -1,25 +0,0 @@
<template>
<div class="grow w-full h-full flex items-center justify-center">
<div class="flex flex-col items-center">
<WrenchScrewdriverIcon
class="h-12 w-12 text-blue-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Under construction
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-lg">
This page hasn't been implemented yet.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
WrenchScrewdriverIcon,
} from "@heroicons/vue/20/solid";
</script>

View File

@ -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 font-display items-left justify-center pl-2"
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"
>
<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"
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}/s</span>
<span v-if="stats.time > 0" class="text-sm"
>{{ formatTime(stats.time) }} left</span
>
</div>
<div class="absolute inset-0 h-full flex flex-row items-end justify-end space-x-[1px]">
<div class="absolute inset-0 h-full flex flex-row items-end justify-end">
<div
v-for="bar in speedHistory"
:style="{ height: `${(bar / speedMax) * 100}%` }"
class="w-[3px] bg-blue-600 rounded-t-full"
class="w-[8px] bg-blue-600/40"
/>
</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)
}}B</span>
}}</span>
/
<span class="">{{ formatKilobytes(element.max / 1000) }}B</span
<span class="">{{ formatKilobytes(element.max / 1000) }}</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 { type DownloadableMetadata, type Game, type GameStatus } from "~/types";
import { GameStatusEnum, 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 = useDownloadHistory();
const speedHistoryMax = computed(() => windowWidth.value / 4);
const speedHistory = useState<Array<number>>(() => []);
const speedHistoryMax = computed(() => windowWidth.value / 8);
const speedMax = computed(
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.1
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.3
);
const previousGameId = useState<string | undefined>('previous_game');
const previousGameId = ref<string | undefined>();
const games: Ref<{
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
@ -122,15 +122,14 @@ 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;
@ -150,10 +149,9 @@ 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, newLength - speedHistoryMax.value);
speedHistory.value.splice(0, 1);
}
checkReset(queue.value);
});
@ -185,7 +183,7 @@ async function cancelGame(meta: DownloadableMetadata) {
}
function formatKilobytes(bytes: number): string {
const units = ["K", "M", "G", "T", "P"];
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let unitIndex = 0;
const scalar = 1000;

View File

@ -1,23 +1,7 @@
<template>
<div class="grow w-full h-full flex items-center justify-center">
<div class="flex flex-col items-center">
<WrenchScrewdriverIcon
class="h-12 w-12 text-blue-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Under construction
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-lg">
This page hasn't been implemented yet.
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { WrenchScrewdriverIcon } from "@heroicons/vue/20/solid";
</script>

View File

@ -7,7 +7,6 @@ export default {
"./plugins/**/*.{js,ts}",
"./app.vue",
"./error.vue",
"../shared/components/**/*.vue"
],
theme: {
extend: {

View File

@ -37,13 +37,6 @@ 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;
};

View File

@ -14,8 +14,7 @@
"@tauri-apps/plugin-os": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.3.0",
"pino": "^9.7.0",
"pino-pretty": "^13.1.1",
"tauri": "^0.15.0"
"pino-pretty": "^13.1.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.7.1"

View File

@ -1,50 +0,0 @@
<template>
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout class="select-none w-screen h-screen">
<NuxtPage />
<ModalStack />
</NuxtLayout>
</template>
<script setup lang="ts">
import "~/composables/downloads.js";
import { invoke } from "@tauri-apps/api/core";
import { useAppState } from "./composables/app-state.js";
import {
initialNavigation,
setupHooks,
} from "./composables/state-navigation.js";
const router = useRouter();
const state = useAppState();
async function fetchState() {
try {
state.value = JSON.parse(await invoke("fetch_state"));
if (!state.value)
throw createError({
statusCode: 500,
statusMessage: `App state is: ${state.value}`,
fatal: true,
});
} catch (e) {
console.error("failed to parse state", e);
throw e;
}
}
await fetchState();
// This is inefficient but apparently we do it lol
router.beforeEach(async () => {
await fetchState();
});
setupHooks();
initialNavigation(state);
useHead({
title: "Drop",
});
</script>

View File

@ -1,84 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
-ms-overflow-style: none; /* IE and Edge /
scrollbar-width: none; / Firefox */
}
/* Hide scrollbar for Chrome, Safari and Opera */
html::-webkit-scrollbar {
display: none;
}
$motiva: (
("MotivaSansThin.ttf", "ttf", 100, normal),
("MotivaSansLight.woff.ttf", "woff", 300, normal),
("MotivaSansRegular.woff.ttf", "woff", 400, normal),
("MotivaSansMedium.woff.ttf", "woff", 500, normal),
("MotivaSansBold.woff.ttf", "woff", 600, normal),
("MotivaSansExtraBold.ttf", "woff", 700, normal),
("MotivaSansBlack.woff.ttf", "woff", 900, normal)
);
$helvetica: (
("Helvetica.woff", "woff", 400, normal),
("Helvetica-Oblique.woff", "woff", 400, italic),
("Helvetica-Bold.woff", "woff", 600, normal),
("Helvetica-BoldOblique.woff", "woff", 600, italic),
("helvetica-light-587ebe5a59211.woff2", "woff2", 300, normal)
);
@each $file, $format, $weight, $style in $motiva {
@font-face {
font-family: "Motiva Sans";
src: url("/fonts/motiva/#{$file}") format($format);
font-weight: $weight;
font-style: $style;
}
}
@each $file, $format, $weight, $style in $helvetica {
@font-face {
font-family: "Helvetica";
src: url("/fonts/helvetica/#{$file}") format($format);
font-weight: $weight;
font-style: $style;
}
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable.ttf");
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable-Italic.ttf");
font-style: italic;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: 4px;
scrollbar-color: #52525b #00000000;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 4px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #52525b;
border-radius: 10px;
border: 3px solid #52525b;
}

View File

@ -1,91 +0,0 @@
<template>
<NuxtLayout name="default">
<div
class="grid min-h-full grid-cols-1 grid-rows-[1fr,auto,1fr] lg:grid-cols-[max(50%,36rem),1fr]"
>
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<Logo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div class="max-w-lg">
<p class="text-base font-semibold leading-8 text-blue-600">
{{ error?.statusCode }}
</p>
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Oh no!
</h1>
<p
v-if="message"
class="mt-3 font-bold text-base leading-7 text-red-500"
>
{{ message }}
</p>
<p class="mt-6 text-base leading-7 text-zinc-400">
An error occurred while responding to your request. If you believe
this to be a bug, please report it. Try signing in and see if it
resolves the issue.
</p>
<div class="mt-10">
<!-- full app reload to fix errors -->
<a
href="/store"
class="text-sm font-semibold leading-7 text-blue-600"
><span aria-hidden="true">&larr;</span> Back to store</a
>
</div>
</div>
</main>
<footer class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3">
<div class="border-t border-zinc-700 bg-zinc-900 py-10">
<nav
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
>
<NuxtLink href="/docs">Documentation</NuxtLink>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-600"
>
<circle cx="1" cy="1" r="1" />
</svg>
<a href="https://discord.gg/NHx46XKJWA" target="_blank"
>Support Discord</a
>
</nav>
</div>
</footer>
<div
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
>
<img
src="@/assets/wallpaper.jpg"
alt=""
class="absolute inset-0 h-full w-full object-cover"
/>
</div>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps({
error: Object as () => NuxtError,
});
const statusCode = props.error?.statusCode;
const message =
props.error?.statusMessage ||
props.error?.message ||
"An unknown error occurred.";
console.error(props.error);
</script>

View File

@ -1,25 +0,0 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2024-04-03",
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
css: ["~/assets/main.scss"],
ssr: false,
extends: [["../libs/drop-base"]],
app: {
baseURL: "/main",
},
devtools: {
enabled: false,
},
});

View File

@ -1,37 +0,0 @@
{
"name": "view",
"private": true,
"version": "0.3.3",
"type": "module",
"scripts": {
"build": "nuxt generate",
"dev": "nuxt dev",
"postinstall": "nuxt prepare",
"tauri": "tauri"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@nuxtjs/tailwindcss": "^6.12.2",
"@tauri-apps/api": "^2.7.0",
"koa": "^2.16.1",
"markdown-it": "^14.1.0",
"micromark": "^4.0.1",
"nuxt": "^3.16.0",
"scss": "^0.2.4",
"vue-router": "latest",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/markdown-it": "^14.1.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"sass-embedded": "^1.79.4",
"tailwindcss": "^3.4.13",
"typescript": "^5.8.3",
"vue-tsc": "^2.2.10"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@ -1,20 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./components/**/*.{js,vue,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./plugins/**/*.{js,ts}",
"./app.vue",
"./error.vue",
],
theme: {
extend: {
fontFamily: {
sans: ["Inter"],
display: ["Motiva Sans"],
},
},
},
plugins: [require("@tailwindcss/forms"), require('@tailwindcss/typography')],
};

View File

@ -1,5 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
"exclude": ["src-tauri/**/*"]
}

View File

@ -1,96 +0,0 @@
import type { Component } from "vue";
export type NavigationItem = {
prefix: string;
route: string;
label: string;
};
export type QuickActionNav = {
icon: Component;
notifications?: number;
action: () => Promise<void>;
};
export type User = {
id: string;
username: string;
admin: boolean;
displayName: string;
profilePictureObjectId: string;
};
export type AppState = {
status: AppStatus;
user?: User;
};
export type Game = {
id: string;
mName: string;
mShortDescription: string;
mDescription: string;
mIconObjectId: string;
mBannerObjectId: string;
mCoverObjectId: string;
mImageLibraryObjectIds: string[];
mImageCarouselObjectIds: string[];
};
export type Collection = {
id: string;
name: string;
isDefault: boolean;
entries: Array<{ gameId: string; game: Game }>;
};
export type GameVersion = {
launchCommandTemplate: string;
};
export enum AppStatus {
NotConfigured = "NotConfigured",
Offline = "Offline",
SignedOut = "SignedOut",
SignedIn = "SignedIn",
SignedInNeedsReauth = "SignedInNeedsReauth",
ServerUnavailable = "ServerUnavailable",
}
export enum GameStatusEnum {
Remote = "Remote",
Queued = "Queued",
Downloading = "Downloading",
Validating = "Validating",
Installed = "Installed",
Updating = "Updating",
Uninstalling = "Uninstalling",
SetupRequired = "SetupRequired",
Running = "Running",
PartiallyInstalled = "PartiallyInstalled",
}
export type GameStatus = {
type: GameStatusEnum;
version_name?: string;
install_dir?: string;
};
export enum DownloadableType {
Game = "Game",
Tool = "Tool",
DLC = "DLC",
Mod = "Mod",
}
export type DownloadableMetadata = {
id: string;
version: string;
downloadType: DownloadableType;
};
export type Settings = {
autostart: boolean;
maxDownloadThreads: number;
forceOffline: boolean;
};

File diff suppressed because it is too large Load Diff

2420
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "drop-app"
version = "0.3.3"
version = "0.3.1"
description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"]
edition = "2024"
@ -65,7 +65,7 @@ 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"}
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"
@ -73,21 +73,8 @@ 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 = { version = "0.1.0", path = "./client" }
database = { path = "./database" }
process = { path = "./process" }
remote = { version = "0.1.0", path = "./remote" }
utils = { path = "./utils" }
games = { version = "0.1.0", path = "./games" }
download_manager = { version = "0.1.0", path = "./download_manager" }
[dependencies.dynfmt]
version = "0.1.5"
features = ["curly"]
@ -117,17 +104,9 @@ version = "2"
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
[dependencies.reqwest]
version = "0.12.22"
version = "0.12"
default-features = false
features = [
"json",
"http2",
"blocking",
"rustls-tls",
"native-tls-alpn",
"rustls-tls-native-roots",
"stream",
]
features = ["json", "http2", "blocking", "rustls-tls", "native-tls-alpn", "rustls-tls-webpki-roots"]
[dependencies.serde]
version = "1"
@ -137,18 +116,3 @@ features = ["derive", "rc"]
lto = true
codegen-units = 1
panic = 'abort'
[workspace]
members = [
"client",
"database",
"process",
"remote",
"utils",
"cloud_saves",
"download_manager",
"games",
]
resolver = "3"

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
[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"

View File

@ -1,12 +0,0 @@
use serde::Serialize;
#[derive(Clone, Copy, Serialize, Eq, PartialEq)]
pub enum AppStatus {
NotConfigured,
Offline,
ServerError,
SignedOut,
SignedIn,
SignedInNeedsReauth,
ServerUnavailable,
}

View File

@ -1,26 +0,0 @@
use database::borrow_db_checked;
use log::debug;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
// New function to sync state on startup
pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> {
let db_handle = borrow_db_checked();
let should_be_enabled = db_handle.settings.autostart;
drop(db_handle);
let manager = app.autolaunch();
let current_state = manager.is_enabled().map_err(|e| e.to_string())?;
if current_state != should_be_enabled {
if should_be_enabled {
manager.enable().map_err(|e| e.to_string())?;
debug!("synced autostart: enabled");
} else {
manager.disable().map_err(|e| e.to_string())?;
debug!("synced autostart: disabled");
}
}
Ok(())
}

View File

@ -1,52 +0,0 @@
use std::{
ffi::OsStr,
path::PathBuf,
process::{Command, Stdio},
sync::LazyLock,
};
use log::info;
pub static COMPAT_INFO: LazyLock<Option<CompatInfo>> = LazyLock::new(create_new_compat_info);
pub static UMU_LAUNCHER_EXECUTABLE: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
let x = get_umu_executable();
info!("{:?}", &x);
x
});
#[derive(Clone)]
pub struct CompatInfo {
pub umu_installed: bool,
}
fn create_new_compat_info() -> Option<CompatInfo> {
#[cfg(target_os = "windows")]
return None;
let has_umu_installed = UMU_LAUNCHER_EXECUTABLE.is_some();
Some(CompatInfo {
umu_installed: has_umu_installed,
})
}
const UMU_BASE_LAUNCHER_EXECUTABLE: &str = "umu-run";
const UMU_INSTALL_DIRS: [&str; 4] = ["/app/share", "/use/local/share", "/usr/share", "/opt"];
fn get_umu_executable() -> Option<PathBuf> {
if check_executable_exists(UMU_BASE_LAUNCHER_EXECUTABLE) {
return Some(PathBuf::from(UMU_BASE_LAUNCHER_EXECUTABLE));
}
for dir in UMU_INSTALL_DIRS {
let p = PathBuf::from(dir).join(UMU_BASE_LAUNCHER_EXECUTABLE);
if check_executable_exists(&p) {
return Some(p);
}
}
None
}
fn check_executable_exists<P: AsRef<OsStr>>(exec: P) -> bool {
let has_umu_installed = Command::new(exec).stdout(Stdio::null()).output();
has_umu_installed.is_ok()
}

View File

@ -1,4 +0,0 @@
pub mod app_status;
pub mod autostart;
pub mod compat;
pub mod user;

View File

@ -1,12 +0,0 @@
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,
}

View File

@ -1,19 +0,0 @@
[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"
serde_with = "3.15.0"
tar = "0.4.44"
tempfile = "3.23.0"
uuid = "1.18.1"
whoami = "1.6.1"
zstd = "0.13.3"

View File

@ -1,234 +0,0 @@
use std::{collections::HashMap, path::PathBuf, str::FromStr};
#[cfg(target_os = "linux")]
use database::platform::Platform;
use database::{GameVersion, db::DATA_ROOT_DIR};
use log::warn;
use crate::error::BackupError;
use super::path::CommonPath;
pub struct BackupManager<'a> {
pub current_platform: Platform,
pub sources: HashMap<(Platform, Platform), &'a (dyn BackupHandler + Sync + Send)>,
}
impl Default for BackupManager<'_> {
fn default() -> Self {
Self::new()
}
}
impl BackupManager<'_> {
pub fn new() -> Self {
BackupManager {
#[cfg(target_os = "windows")]
current_platform: Platform::Windows,
#[cfg(target_os = "macos")]
current_platform: Platform::MacOs,
#[cfg(target_os = "linux")]
current_platform: Platform::Linux,
sources: HashMap::from([
// Current platform to target platform
(
(Platform::Windows, Platform::Windows),
&WindowsBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
(
(Platform::Linux, Platform::Linux),
&LinuxBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
(
(Platform::MacOs, Platform::MacOs),
&MacBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
]),
}
}
}
pub trait BackupHandler: Send + Sync {
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
}
fn store_user_id_translate(
&self,
_path: &PathBuf,
game: &GameVersion,
) -> Result<PathBuf, BackupError> {
PathBuf::from_str(&game.game_id).map_err(|_| BackupError::ParseError)
}
fn os_user_name_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
Ok(PathBuf::from_str(&whoami::username()).unwrap())
}
fn win_app_data_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected Windows Reference in Backup <winAppData>");
Err(BackupError::InvalidSystem)
}
fn win_local_app_data_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected Windows Reference in Backup <winLocalAppData>");
Err(BackupError::InvalidSystem)
}
fn win_local_app_data_low_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected Windows Reference in Backup <winLocalAppDataLow>");
Err(BackupError::InvalidSystem)
}
fn win_documents_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected Windows Reference in Backup <winDocuments>");
Err(BackupError::InvalidSystem)
}
fn win_public_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected Windows Reference in Backup <winPublic>");
Err(BackupError::InvalidSystem)
}
fn win_program_data_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected Windows Reference in Backup <winProgramData>");
Err(BackupError::InvalidSystem)
}
fn win_dir_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected Windows Reference in Backup <winDir>");
Err(BackupError::InvalidSystem)
}
fn xdg_data_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected XDG Reference in Backup <xdgData>");
Err(BackupError::InvalidSystem)
}
fn xdg_config_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
warn!("Unexpected XDG Reference in Backup <xdgConfig>");
Err(BackupError::InvalidSystem)
}
fn skip_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(PathBuf::new())
}
}
pub struct LinuxBackupManager {}
impl BackupHandler for LinuxBackupManager {
fn xdg_config_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
CommonPath::Data.get().ok_or(BackupError::NotFound)
}
fn xdg_data_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
CommonPath::Config.get().ok_or(BackupError::NotFound)
}
}
pub struct WindowsBackupManager {}
impl BackupHandler for WindowsBackupManager {
fn win_app_data_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
CommonPath::Config.get().ok_or(BackupError::NotFound)
}
fn win_local_app_data_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
CommonPath::DataLocal.get().ok_or(BackupError::NotFound)
}
fn win_local_app_data_low_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
CommonPath::DataLocalLow
.get()
.ok_or(BackupError::NotFound)
}
fn win_dir_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
Ok(PathBuf::from_str("C:/Windows").unwrap())
}
fn win_documents_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
CommonPath::Document.get().ok_or(BackupError::NotFound)
}
fn win_program_data_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
Ok(PathBuf::from_str("C:/ProgramData").unwrap())
}
fn win_public_translate(
&self,
_path: &PathBuf,
_game: &GameVersion,
) -> Result<PathBuf, BackupError> {
CommonPath::Public.get().ok_or(BackupError::NotFound)
}
}
pub struct MacBackupManager {}
impl BackupHandler for MacBackupManager {}

View File

@ -1,27 +0,0 @@
use std::fmt::Display;
use serde_with::SerializeDisplay;
#[derive(Debug, SerializeDisplay, Clone, Copy)]
pub enum BackupError {
InvalidSystem,
NotFound,
ParseError,
}
impl Display for BackupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
BackupError::InvalidSystem => "Attempted to generate path for invalid system",
BackupError::NotFound => "Could not generate or find path",
BackupError::ParseError => "Failed to parse path",
};
write!(f, "{}", s)
}
}

View File

@ -1,8 +0,0 @@
pub mod backup_manager;
pub mod conditions;
pub mod error;
pub mod metadata;
pub mod normalise;
pub mod path;
pub mod placeholder;
pub mod resolver;

View File

@ -1,15 +0,0 @@
[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"

View File

@ -1,45 +0,0 @@
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)
}
}

View File

@ -1,14 +0,0 @@
#![feature(nonpoison_rwlock)]
pub mod db;
pub mod debug;
pub mod interface;
pub mod models;
pub mod platform;
pub use db::DB;
pub use interface::{borrow_db_checked, borrow_db_mut_checked};
pub use models::data::{
ApplicationTransientStatus, Database, DatabaseApplications, DatabaseAuth, DownloadType,
DownloadableMetadata, GameDownloadStatus, GameVersion, Settings,
};

View File

@ -1,46 +0,0 @@
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),
}
}
}

View File

@ -1,17 +0,0 @@
[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" }

View File

@ -1,80 +0,0 @@
use humansize::{BINARY, format_size};
use std::{
fmt::{Display, Formatter},
io,
sync::{Arc, mpsc::SendError},
};
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)]
pub enum ApplicationDownloadError {
NotInitialized,
Communication(RemoteAccessError),
DiskFull(u64, u64),
#[allow(dead_code)]
Checksum,
Lock,
IoError(Arc<io::Error>),
DownloadError(RemoteAccessError),
}
impl Display for ApplicationDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ApplicationDownloadError::NotInitialized => {
write!(f, "Download not initalized, did something go wrong?")
}
ApplicationDownloadError::DiskFull(required, available) => write!(
f,
"Game requires {}, {} remaining left on disk.",
format_size(*required, BINARY),
format_size(*available, BINARY),
),
ApplicationDownloadError::Communication(error) => write!(f, "{error}"),
ApplicationDownloadError::Lock => write!(
f,
"failed to acquire lock. Something has gone very wrong internally. Please restart the application"
),
ApplicationDownloadError::Checksum => {
write!(f, "checksum failed to validate for download")
}
ApplicationDownloadError::IoError(error) => write!(f, "io error: {error}"),
ApplicationDownloadError::DownloadError(error) => {
write!(f, "Download failed with error {error:?}")
}
}
}
}
impl From<io::Error> for ApplicationDownloadError {
fn from(value: io::Error) -> Self {
ApplicationDownloadError::IoError(Arc::new(value))
}
}

View File

@ -1,24 +0,0 @@
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(Serialize, Clone)]
pub struct StatsUpdateEvent {
pub speed: usize,
pub time: usize,
}

View File

@ -1,44 +0,0 @@
#![feature(duration_millis_float)]
#![feature(nonpoison_mutex)]
#![feature(sync_nonpoison)]
use std::{ops::Deref, sync::OnceLock};
use tauri::AppHandle;
use crate::{
download_manager_builder::DownloadManagerBuilder, download_manager_frontend::DownloadManager,
};
pub mod download_manager_builder;
pub mod download_manager_frontend;
pub mod downloadable;
pub mod error;
pub mod frontend_updates;
pub mod util;
pub static DOWNLOAD_MANAGER: DownloadManagerWrapper = DownloadManagerWrapper::new();
pub struct DownloadManagerWrapper(OnceLock<DownloadManager>);
impl DownloadManagerWrapper {
const fn new() -> Self {
DownloadManagerWrapper(OnceLock::new())
}
pub fn init(app_handle: AppHandle) {
DOWNLOAD_MANAGER
.0
.set(DownloadManagerBuilder::build(app_handle))
.expect("Failed to initialise download manager");
}
}
impl Deref for DownloadManagerWrapper {
type Target = DownloadManager;
fn deref(&self) -> &Self::Target {
match self.0.get() {
Some(download_manager) => download_manager,
None => unreachable!("Download manager should always be initialised"),
}
}
}

View File

@ -1,26 +0,0 @@
[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"

View File

@ -1,716 +0,0 @@
use database::{
ApplicationTransientStatus, DownloadType, DownloadableMetadata, borrow_db_checked,
borrow_db_mut_checked,
};
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 std::collections::{HashMap, HashSet};
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};
use std::time::Instant;
use tauri::AppHandle;
use utils::{app_emit, lock, send};
#[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,
pub version: String,
pub control_flag: DownloadThreadControl,
buckets: Mutex<Vec<DownloadBucket>>,
context_map: Mutex<HashMap<String, bool>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
sender: Sender<DownloadManagerSignal>,
pub dropdata: DropData,
status: Mutex<DownloadStatus>,
}
impl GameDownloadAgent {
pub async fn new_from_index(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Result<Self, ApplicationDownloadError> {
let base_dir = {
let db_lock = borrow_db_checked();
db_lock.applications.install_dirs[target_download_dir].clone()
};
Self::new(id, version, base_dir, sender).await
}
pub async fn new(
id: String,
version: String,
base_dir: PathBuf,
sender: Sender<DownloadManagerSignal>,
) -> Result<Self, ApplicationDownloadError> {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
let base_dir_path = Path::new(&base_dir);
let data_base_dir_path = base_dir_path.join(id.clone());
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,
control_flag,
manifest: Mutex::new(None),
buckets: Mutex::new(Vec::new()),
context_map: Mutex::new(HashMap::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
dropdata: stored_manifest,
status: Mutex::new(DownloadStatus::Queued),
};
result.ensure_manifest_exists().await?;
let required_space = lock!(result.manifest)
.as_ref()
.unwrap()
.values()
.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;
if required_space > available_space {
return Err(ApplicationDownloadError::DiskFull(
required_space,
available_space,
));
}
Ok(result)
}
// Blocking
pub fn setup_download(&self, app_handle: &AppHandle) -> Result<(), ApplicationDownloadError> {
let mut db_lock = borrow_db_mut_checked();
let status = ApplicationTransientStatus::Downloading {
version_name: self.version.clone(),
};
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
// Don't use GameStatusManager because this game isn't installed
push_game_update(app_handle, &self.metadata().id, None, (None, Some(status)));
if !self.check_manifest_exists() {
return Err(ApplicationDownloadError::NotInitialized);
}
self.ensure_buckets()?;
self.control_flag.set(DownloadThreadControlFlag::Go);
Ok(())
}
// Blocking
pub fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
self.setup_download(app_handle)?;
let timer = Instant::now();
info!("beginning download for {}...", self.metadata().id);
let res = self.run().map_err(ApplicationDownloadError::Communication);
debug!(
"{} took {}ms to download",
self.id,
timer.elapsed().as_millis()
);
res
}
pub fn check_manifest_exists(&self) -> bool {
lock!(self.manifest).is_some()
}
pub async fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
if lock!(self.manifest).is_some() {
return Ok(());
}
self.download_manifest().await
}
async fn download_manifest(&self) -> Result<(), ApplicationDownloadError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(
&["/api/v1/client/game/manifest"],
&[("id", &self.id), ("version", &self.version)],
)
.map_err(ApplicationDownloadError::Communication)?;
let response = client
.get(url)
.header("Authorization", generate_authorization_header())
.send()
.await
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::ManifestDownloadFailed(
response.status(),
response.text().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);
return Ok(());
}
Err(ApplicationDownloadError::Lock)
}
// Sets it up for both download and validate
fn setup_progress(&self) {
let buckets = lock!(self.buckets);
let chunk_count = buckets.iter().map(|e| e.drops.len()).sum();
let total_length = buckets
.iter()
.map(|bucket| bucket.drops.iter().map(|e| e.length).sum::<usize>())
.sum();
self.progress.set_max(total_length);
self.progress.set_size(chunk_count);
self.progress.reset();
}
pub fn ensure_buckets(&self) -> Result<(), ApplicationDownloadError> {
if lock!(self.buckets).is_empty() {
self.generate_buckets()?;
}
*lock!(self.context_map) = self.dropdata.get_contexts();
Ok(())
}
pub fn generate_buckets(&self) -> Result<(), ApplicationDownloadError> {
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)?;
let mut buckets = Vec::new();
let mut current_buckets = HashMap::<String, DownloadBucket>::new();
let mut current_bucket_sizes = HashMap::<String, usize>::new();
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
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()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
let mut file_running_offset = 0;
for (index, length) in chunk.lengths.iter().enumerate() {
let drop = DownloadDrop {
filename: raw_path.to_string(),
start: file_running_offset,
length: *length,
checksum: chunk.checksums[index].clone(),
permissions: chunk.permissions,
path: path.clone(),
index,
};
file_running_offset += *length;
if *length >= TARGET_BUCKET_SIZE {
// They get their own bucket
buckets.push(DownloadBucket {
game_id: game_id.clone(),
version: chunk.version_name.clone(),
drops: vec![drop],
});
continue;
}
let current_bucket_size = current_bucket_sizes
.entry(chunk.version_name.clone())
.or_insert_with(|| 0);
let c_version_name = chunk.version_name.clone();
let c_game_id = game_id.clone();
let current_bucket = current_buckets
.entry(chunk.version_name.clone())
.or_insert_with(|| DownloadBucket {
game_id: c_game_id,
version: c_version_name,
drops: vec![],
});
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
buckets.push(current_bucket.clone());
*current_bucket = DownloadBucket {
game_id: game_id.clone(),
version: chunk.version_name.clone(),
drops: vec![],
};
*current_bucket_size = 0;
}
current_bucket.drops.push(drop);
*current_bucket_size += *length;
}
#[cfg(target_os = "linux")]
if file_running_offset > 0 && !already_exists {
let _ = fallocate(file, FallocateFlags::empty(), 0, file_running_offset as u64);
}
}
for (_, bucket) in current_buckets.into_iter() {
if !bucket.drops.is_empty() {
buckets.push(bucket);
}
}
info!("buckets: {}", buckets.len());
let existing_contexts = self.dropdata.get_contexts();
self.dropdata.set_contexts(
&buckets
.iter()
.flat_map(|x| x.drops.iter().map(|v| v.checksum.clone()))
.map(|x| {
let contains = existing_contexts.get(&x).unwrap_or(&false);
(x, *contains)
})
.collect::<Vec<(String, bool)>>(),
);
*lock!(self.buckets) = buckets;
Ok(())
}
fn run(&self) -> Result<bool, RemoteAccessError> {
self.setup_progress();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
debug!(
"downloading game: {} with {} threads",
self.id, max_download_threads
);
let pool = ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
.unwrap_or_else(|_| {
panic!("failed to build thread pool with {max_download_threads} threads")
});
let buckets = lock!(self.buckets);
let mut download_contexts = HashMap::<String, DownloadContext>::new();
let versions = buckets
.iter()
.map(|e| &e.version)
.collect::<HashSet<_>>()
.into_iter()
.cloned()
.collect::<Vec<String>>();
info!("downloading across these versions: {versions:?}");
let completed_contexts = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_contexts.clone();
for version in versions {
let download_context = DROP_CLIENT_SYNC
.post(generate_url(&["/api/v2/client/context"], &[])?)
.json(&ManifestBody {
game: self.id.clone(),
version: version.clone(),
})
.header("Authorization", generate_authorization_header())
.send()?;
if download_context.status() != 200 {
return Err(RemoteAccessError::InvalidResponse(download_context.json()?));
}
let download_context = download_context.json::<DownloadContext>()?;
info!(
"download context: ({}) {}",
&version, download_context.context
);
download_contexts.insert(version, download_context);
}
let download_contexts = &download_contexts;
pool.scope(|scope| {
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();
let progress = self.progress.get(index);
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
// If we've done this one already, skip it
// Note to future DecDuck, DropData gets loaded into context_map
let todo_drops = bucket
.drops
.into_iter()
.filter(|e| {
let todo = !*context_map.get(&e.checksum).unwrap_or(&false);
if !todo {
progress_handle.skip(e.length);
}
todo
})
.collect::<Vec<DownloadDrop>>();
if todo_drops.is_empty() {
continue;
};
bucket.drops = todo_drops;
let sender = self.sender.clone();
let download_context =
download_contexts.get(&bucket.version).unwrap_or_else(|| {
panic!(
"Could not get bucket version {}. Corrupted state.",
bucket.version
)
});
scope.spawn(move |_| {
// 3 attempts
for i in 0..RETRY_COUNT {
let loop_progress_handle = progress_handle.clone();
match download_game_bucket(
&bucket,
download_context,
&self.control_flag,
loop_progress_handle,
) {
Ok(true) => {
for drop in bucket.drops {
completed_contexts.push(drop.checksum);
}
return;
}
Ok(false) => return,
Err(e) => {
warn!("game download agent error: {e}");
let retry = matches!(
&e,
ApplicationDownloadError::Communication(_)
| ApplicationDownloadError::Checksum
| ApplicationDownloadError::Lock
| ApplicationDownloadError::IoError(_)
);
if i == RETRY_COUNT - 1 || !retry {
warn!("retry logic failed, not re-attempting.");
send!(sender, DownloadManagerSignal::Error(e));
return;
}
}
}
}
});
}
});
let newly_completed = completed_contexts.clone();
let completed_lock_len = {
let mut context_map_lock = lock!(self.context_map);
for (_, item) in newly_completed.iter() {
context_map_lock.insert(item.clone(), true);
}
context_map_lock.values().filter(|x| **x).count()
};
let context_map_lock = lock!(self.context_map);
let contexts = buckets
.iter()
.flat_map(|x| x.drops.iter().map(|e| e.checksum.clone()))
.map(|x| {
let completed = context_map_lock.get(&x).unwrap_or(&false);
(x, *completed)
})
.collect::<Vec<(String, bool)>>();
drop(context_map_lock);
self.dropdata.set_contexts(&contexts);
self.dropdata.write();
// If there are any contexts left which are false
if !contexts.iter().all(|x| x.1) {
info!(
"download agent for {} exited without completing ({}/{}) ({} buckets)",
self.id.clone(),
completed_lock_len,
contexts.len(),
buckets.len()
);
return Ok(false);
}
Ok(true)
}
fn setup_validate(&self, app_handle: &AppHandle) {
self.setup_progress();
self.control_flag.set(DownloadThreadControlFlag::Go);
let status = ApplicationTransientStatus::Validating {
version_name: self.version.clone(),
};
let mut db_lock = borrow_db_mut_checked();
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
push_game_update(app_handle, &self.metadata().id, None, (None, Some(status)));
}
pub fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
self.setup_validate(app_handle);
let buckets = lock!(self.buckets);
let contexts: Vec<DropValidateContext> = buckets
.clone()
.into_iter()
.flat_map(|e| -> Vec<DropValidateContext> { e.into() })
.collect();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
info!("{} validation contexts", contexts.len());
let pool = ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
.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| {
for (index, context) in contexts.iter().enumerate() {
let current_progress = self.progress.get(index);
let progress_handle = ProgressHandle::new(current_progress, self.progress.clone());
let invalid_chunks_scoped = invalid_chunks.clone();
let sender = self.sender.clone();
scope.spawn(move |_| {
match validate_game_chunk(context, &self.control_flag, progress_handle) {
Ok(true) => {}
Ok(false) => {
invalid_chunks_scoped.push(context.checksum.clone());
}
Err(e) => {
error!("{e}");
send!(sender, DownloadManagerSignal::Error(e));
}
}
});
}
});
// If there are any contexts left which are false
if !invalid_chunks.is_empty() {
info!("validation of game id {} failed", self.id);
for context in invalid_chunks.iter() {
self.dropdata.set_context(context.1.clone(), false);
}
self.dropdata.write();
return Ok(false);
}
Ok(true)
}
pub fn cancel(&self, app_handle: &AppHandle) {
// See docs on usage
set_partially_installed(
&self.metadata(),
self.dropdata.base_path.display().to_string(),
Some(app_handle),
);
self.dropdata.write();
}
}
impl Downloadable for GameDownloadAgent {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
*lock!(self.status) = DownloadStatus::Downloading;
self.download(app_handle)
}
fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
*lock!(self.status) = DownloadStatus::Validating;
self.validate(app_handle)
}
fn progress(&self) -> Arc<ProgressObject> {
self.progress.clone()
}
fn control_flag(&self) -> DownloadThreadControl {
self.control_flag.clone()
}
fn metadata(&self) -> DownloadableMetadata {
DownloadableMetadata {
id: self.id.clone(),
version: Some(self.version.clone()),
download_type: DownloadType::Game,
}
}
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) {
*lock!(self.status) = DownloadStatus::Error;
app_emit!(app_handle, "download_error", error.to_string());
error!("error while managing download: {error:?}");
let mut handle = borrow_db_mut_checked();
handle
.applications
.transient_statuses
.remove(&self.metadata());
push_game_update(
app_handle,
&self.id,
None,
GameStatusManager::fetch_state(&self.id, &handle),
);
}
fn on_complete(&self, app_handle: &tauri::AppHandle) {
match on_game_complete(
&self.metadata(),
self.dropdata.base_path.to_string_lossy().to_string(),
app_handle,
) {
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);
}
fn status(&self) -> DownloadStatus {
lock!(self.status).clone()
}
}

View File

@ -1,295 +0,0 @@
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,
destination: BufWriter<W>,
progress: ProgressHandle,
}
impl DropWriter<File> {
fn new(path: PathBuf, progress: ProgressHandle) -> Result<Self, io::Error> {
let destination = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
Ok(Self {
destination: BufWriter::with_capacity(1024 * 1024, destination),
hasher: Context::new(),
progress,
})
}
fn finish(mut self) -> io::Result<Digest> {
self.flush()?;
Ok(self.hasher.finalize())
}
}
// Write automatically pushes to file and hasher
impl Write for DropWriter<File> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.hasher
.write_all(buf)
.map_err(|e| io::Error::other(format!("Unable to write to hasher: {e}")))?;
let bytes_written = self.destination.write(buf)?;
self.progress.add(bytes_written);
Ok(bytes_written)
}
fn flush(&mut self) -> io::Result<()> {
self.hasher.flush()?;
self.destination.flush()
}
}
// Seek moves around destination output
impl Seek for DropWriter<File> {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
self.destination.seek(pos)
}
}
pub struct DropDownloadPipeline<'a, R: Read, W: Write> {
pub source: R,
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> {
fn new(
source: Response,
drops: Vec<DownloadDrop>,
control_flag: &'a DownloadThreadControl,
progress: ProgressHandle,
) -> Result<Self, io::Error> {
Ok(Self {
source,
destination: drops
.iter()
.map(|drop| DropWriter::new(drop.path.clone(), progress.clone()))
.try_collect()?,
drops,
control_flag,
progress,
})
}
fn copy(&mut self) -> Result<bool, io::Error> {
let mut copy_buffer = [0u8; MAX_PACKET_LENGTH];
for (index, drop) in self.drops.iter().enumerate() {
let destination = self
.destination
.get_mut(index)
.ok_or(io::Error::other("no destination"))?;
let mut remaining = drop.length;
if drop.start != 0 {
destination.seek(SeekFrom::Start(drop.start as u64))?;
}
let mut last_bump = 0;
loop {
let size = MAX_PACKET_LENGTH.min(remaining);
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(&copy_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;
};
}
if self.control_flag.get() == DownloadThreadControlFlag::Stop {
return Ok(false);
}
}
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
.into_iter()
.map(|e| e.finish())
.try_collect()?;
Ok(checksums)
}
}
pub fn download_game_bucket(
bucket: &DownloadBucket,
ctx: &DownloadContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> Result<bool, ApplicationDownloadError> {
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let start = Instant::now();
let header = generate_authorization_header();
let url = generate_url(&["/api/v2/client/chunk"], &[])
.map_err(ApplicationDownloadError::Communication)?;
let body = ChunkBody::create(ctx, &bucket.drops);
let response = DROP_CLIENT_SYNC
.post(url)
.json(&body)
.header("Authorization", header)
.send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
info!("chunk request got status code: {}", response.status());
let raw_res = response.text().map_err(|e| {
ApplicationDownloadError::Communication(RemoteAccessError::FetchError(e.into()))
})?;
info!("{raw_res}");
if let Ok(err) = serde_json::from_str::<DropServerError>(&raw_res) {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(err),
));
}
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::UnparseableResponse(raw_res),
));
}
let lengths = response
.headers()
.get("Content-Lengths")
.ok_or(ApplicationDownloadError::Communication(
RemoteAccessError::UnparseableResponse("missing Content-Lengths header".to_owned()),
))?
.to_str()
.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(
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(
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)))?;
let completed = pipeline
.copy()
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
if !completed {
return Ok(false);
}
// If we complete the file, set the permissions (if on Linux)
#[cfg(unix)]
{
for drop in bucket.drops.iter() {
let permissions = Permissions::from_mode(drop.permissions);
set_permissions(drop.path.clone(), permissions)
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
}
}
let checksums = pipeline
.finish()
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
for (index, drop) in bucket.drops.iter().enumerate() {
let res = hex::encode(**checksums.get(index).unwrap());
if res != drop.checksum {
warn!("context didn't match... doing nothing because we will validate later.");
// return Ok(false);
// return Err(ApplicationDownloadError::Checksum);
}
}
Ok(true)
}

View File

@ -1,29 +0,0 @@
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"
)
}
}
)
}
}

View File

@ -1,98 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize)]
// Drops go in buckets
pub struct DownloadDrop {
pub index: usize,
pub filename: String,
pub path: PathBuf,
pub start: usize,
pub length: usize,
pub checksum: String,
pub permissions: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct DownloadBucket {
pub game_id: String,
pub version: String,
pub drops: Vec<DownloadDrop>,
}
#[derive(Deserialize)]
pub struct DownloadContext {
pub context: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChunkBodyFile {
filename: String,
chunk_index: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChunkBody {
pub context: String,
pub files: Vec<ChunkBodyFile>,
}
#[derive(Serialize)]
pub struct ManifestBody {
pub game: String,
pub version: String,
}
impl ChunkBody {
pub fn create(context: &DownloadContext, drops: &[DownloadDrop]) -> ChunkBody {
Self {
context: context.context.clone(),
files: drops
.iter()
.map(|e| ChunkBodyFile {
filename: e.filename.clone(),
chunk_index: e.index,
})
.collect(),
}
}
}
pub type DropManifest = HashMap<String, DropChunk>;
#[derive(Serialize, Deserialize, Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DropChunk {
pub permissions: u32,
pub ids: Vec<String>,
pub checksums: Vec<String>,
pub lengths: Vec<usize>,
pub version_name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DropValidateContext {
pub index: usize,
pub offset: usize,
pub path: PathBuf,
pub checksum: String,
pub length: usize,
}
impl From<DownloadBucket> for Vec<DropValidateContext> {
fn from(value: DownloadBucket) -> Self {
value
.drops
.into_iter()
.map(|e| DropValidateContext {
index: e.index,
offset: e.start,
path: e.path,
checksum: e.checksum,
length: e.length,
})
.collect()
}
}

View File

@ -1,300 +0,0 @@
use bitcode::{Decode, Encode};
use database::{
ApplicationTransientStatus, Database, DownloadableMetadata, GameDownloadStatus, GameVersion,
borrow_db_checked, borrow_db_mut_checked,
};
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 std::fs::remove_dir_all;
use std::thread::spawn;
use tauri::AppHandle;
use utils::app_emit;
use crate::state::{GameStatusManager, GameStatusWithTransient};
#[derive(Serialize, Deserialize, Debug)]
pub struct FetchGameStruct {
game: Game,
status: GameStatusWithTransient,
version: Option<GameVersion>,
}
impl FetchGameStruct {
pub fn new(game: Game, status: GameStatusWithTransient, version: Option<GameVersion>) -> Self {
Self {
game,
status,
version,
}
}
}
#[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>,
}
impl Game {
pub fn id(&self) -> &String {
&self.id
}
}
#[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,
}
impl FrontendGameOptions {
pub fn launch_string(&self) -> &String {
&self.launch_string
}
}

View File

@ -1,19 +0,0 @@
[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"
games = { version = "0.1.0", path = "../games" }
log = "0.4.28"
page_size = "0.6.0"
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" }

View File

@ -1,41 +0,0 @@
#![feature(nonpoison_mutex)]
#![feature(sync_nonpoison)]
use std::{
ops::Deref,
sync::{OnceLock, nonpoison::Mutex},
};
use tauri::AppHandle;
use crate::process_manager::ProcessManager;
pub static PROCESS_MANAGER: ProcessManagerWrapper = ProcessManagerWrapper::new();
pub mod error;
pub mod format;
pub mod process_handlers;
pub mod process_manager;
pub struct ProcessManagerWrapper(OnceLock<Mutex<ProcessManager<'static>>>);
impl ProcessManagerWrapper {
const fn new() -> Self {
ProcessManagerWrapper(OnceLock::new())
}
pub fn init(app_handle: AppHandle) {
PROCESS_MANAGER
.0
.set(Mutex::new(ProcessManager::new(app_handle)))
.unwrap_or_else(|_| panic!("Failed to initialise Process Manager")); // Using panic! here because we can't implement Debug
}
}
impl Deref for ProcessManagerWrapper {
type Target = Mutex<ProcessManager<'static>>;
fn deref(&self) -> &Self::Target {
match self.0.get() {
Some(process_manager) => process_manager,
None => unreachable!("Download manager should always be initialised"),
}
}
}

View File

@ -1,23 +0,0 @@
[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" }

View File

@ -1,152 +0,0 @@
use std::{collections::HashMap, env};
use chrono::Utc;
use client::{app_status::AppStatus, user::User};
use database::{DatabaseAuth, 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")]
pub struct HandshakeRequestBody {
client_id: String,
token: String,
}
impl HandshakeRequestBody {
pub fn new(client_id: String, token: String) -> Self {
Self { client_id, token }
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HandshakeResponse {
private: String,
certificate: String,
id: String,
}
impl From<HandshakeResponse> for DatabaseAuth {
fn from(value: HandshakeResponse) -> Self {
DatabaseAuth::new(value.private, value.certificate, value.id, None)
}
}
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)
}

View File

@ -1,82 +0,0 @@
use database::{DB, interface::DatabaseImpls};
use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder};
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))
}
}
}
}
}

View File

@ -1,31 +0,0 @@
use database::{DB, interface::DatabaseImpls};
use url::Url;
use crate::{
auth::generate_authorization_header, error::RemoteAccessError, utils::DROP_CLIENT_ASYNC,
};
pub fn generate_url<T: AsRef<str>>(
path_components: &[T],
query: &[(T, T)],
) -> Result<Url, RemoteAccessError> {
let mut base_url = DB.fetch_base_url();
for endpoint in path_components {
base_url = base_url.join(endpoint.as_ref())?;
}
{
let mut queries = base_url.query_pairs_mut();
for (param, val) in query {
queries.append_pair(param.as_ref(), val.as_ref());
}
}
Ok(base_url)
}
pub async fn make_authenticated_get(url: Url) -> Result<reqwest::Response, reqwest::Error> {
DROP_CLIENT_ASYNC
.get(url)
.header("Authorization", generate_authorization_header())
.send()
.await
}

View File

@ -1,108 +0,0 @@
use std::str::FromStr;
use database::borrow_db_checked;
use http::{Request, Response, StatusCode, Uri, uri::PathAndQuery};
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)
}

View File

@ -1,119 +0,0 @@
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")]
pub struct DropHealthcheck {
app_name: String,
}
impl DropHealthcheck {
pub fn app_name(&self) -> &String {
&self.app_name
}
}
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")
}

View File

@ -1,41 +1,9 @@
use std::sync::nonpoison::Mutex;
use database::{borrow_db_checked, borrow_db_mut_checked};
use download_manager::DOWNLOAD_MANAGER;
use log::{debug, error};
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
use log::debug;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
use crate::AppState;
#[tauri::command]
pub fn fetch_state(state: tauri::State<'_, Mutex<AppState>>) -> Result<String, String> {
let guard = state.lock();
let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?;
drop(guard);
Ok(cloned_state)
}
#[tauri::command]
pub fn quit(app: tauri::AppHandle) {
cleanup_and_exit(&app);
}
pub fn cleanup_and_exit(app: &AppHandle) {
debug!("cleaning up and exiting application");
match DOWNLOAD_MANAGER.ensure_terminated() {
Ok(res) => match res {
Ok(()) => debug!("download manager terminated correctly"),
Err(()) => error!("download manager failed to terminate correctly"),
},
Err(e) => panic!("{e:?}"),
}
app.exit(0);
}
#[tauri::command]
pub fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> {
pub fn toggle_autostart_logic(app: AppHandle, enabled: bool) -> Result<(), String> {
let manager = app.autolaunch();
if enabled {
manager.enable().map_err(|e| e.to_string())?;
@ -48,11 +16,13 @@ pub fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> {
// Store the state in DB
let mut db_handle = borrow_db_mut_checked();
db_handle.settings.autostart = enabled;
drop(db_handle);
Ok(())
}
#[tauri::command]
pub fn get_autostart_enabled(app: AppHandle) -> Result<bool, tauri_plugin_autostart::Error> {
pub fn get_autostart_enabled_logic(app: AppHandle) -> Result<bool, tauri_plugin_autostart::Error> {
// First check DB state
let db_handle = borrow_db_checked();
let db_state = db_handle.settings.autostart;
drop(db_handle);
@ -72,3 +42,34 @@ pub fn get_autostart_enabled(app: AppHandle) -> Result<bool, tauri_plugin_autost
Ok(db_state)
}
// New function to sync state on startup
pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> {
let db_handle = borrow_db_checked();
let should_be_enabled = db_handle.settings.autostart;
drop(db_handle);
let manager = app.autolaunch();
let current_state = manager.is_enabled().map_err(|e| e.to_string())?;
if current_state != should_be_enabled {
if should_be_enabled {
manager.enable().map_err(|e| e.to_string())?;
debug!("synced autostart: enabled");
} else {
manager.disable().map_err(|e| e.to_string())?;
debug!("synced autostart: disabled");
}
}
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)
}

View File

@ -0,0 +1,23 @@
use log::{debug, error};
use tauri::AppHandle;
use crate::AppState;
#[tauri::command]
pub fn quit(app: tauri::AppHandle, state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>) {
cleanup_and_exit(&app, &state);
}
pub fn cleanup_and_exit(app: &AppHandle, state: &tauri::State<'_, std::sync::Mutex<AppState<'_>>>) {
debug!("cleaning up and exiting application");
let download_manager = state.lock().unwrap().download_manager.clone();
match download_manager.ensure_terminated() {
Ok(res) => match res {
Ok(()) => debug!("download manager terminated correctly"),
Err(()) => error!("download manager failed to terminate correctly"),
},
Err(e) => panic!("{e:?}"),
}
app.exit(0);
}

View File

@ -0,0 +1,11 @@
use crate::AppState;
#[tauri::command]
pub fn fetch_state(
state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>,
) -> Result<String, String> {
let guard = state.lock().unwrap();
let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?;
drop(guard);
Ok(cloned_state)
}

View File

@ -0,0 +1,3 @@
pub mod autostart;
pub mod cleanup;
pub mod commands;

View File

@ -0,0 +1,102 @@
use std::{collections::HashMap, path::PathBuf, str::FromStr};
use log::warn;
use crate::{database::db::{GameVersion, DATA_ROOT_DIR}, error::backup_error::BackupError, process::process_manager::Platform};
use super::path::CommonPath;
pub struct BackupManager<'a> {
pub current_platform: Platform,
pub sources: HashMap<(Platform, Platform), &'a (dyn BackupHandler + Sync + Send)>,
}
impl BackupManager<'_> {
pub fn new() -> Self {
BackupManager {
#[cfg(target_os = "windows")]
current_platform: Platform::Windows,
#[cfg(target_os = "macos")]
current_platform: Platform::MacOs,
#[cfg(target_os = "linux")]
current_platform: Platform::Linux,
sources: HashMap::from([
// Current platform to target platform
(
(Platform::Windows, Platform::Windows),
&WindowsBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
(
(Platform::Linux, Platform::Linux),
&LinuxBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
(
(Platform::MacOs, Platform::MacOs),
&MacBackupManager {} as &(dyn BackupHandler + Sync + Send),
),
]),
}
}
}
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 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 }
fn store_user_id_translate(&self, _path: &PathBuf, game: &GameVersion) -> Result<PathBuf, BackupError> { PathBuf::from_str(&game.game_id).map_err(|_| BackupError::ParseError) }
fn os_user_name_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::from_str(&whoami::username()).unwrap()) }
fn win_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winAppData>"); Err(BackupError::InvalidSystem) }
fn win_local_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winLocalAppData>"); Err(BackupError::InvalidSystem) }
fn win_local_app_data_low_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winLocalAppDataLow>"); Err(BackupError::InvalidSystem) }
fn win_documents_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winDocuments>"); Err(BackupError::InvalidSystem) }
fn win_public_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winPublic>"); Err(BackupError::InvalidSystem) }
fn win_program_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winProgramData>"); Err(BackupError::InvalidSystem) }
fn win_dir_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected Windows Reference in Backup <winDir>"); Err(BackupError::InvalidSystem) }
fn xdg_data_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected XDG Reference in Backup <xdgData>"); Err(BackupError::InvalidSystem) }
fn xdg_config_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> { warn!("Unexpected XDG Reference in Backup <xdgConfig>"); Err(BackupError::InvalidSystem) }
fn skip_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> { Ok(PathBuf::new()) }
}
pub struct LinuxBackupManager {}
impl BackupHandler for LinuxBackupManager {
fn xdg_config_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Data.get().ok_or(BackupError::NotFound)?)
}
fn xdg_data_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Config.get().ok_or(BackupError::NotFound)?)
}
}
pub struct WindowsBackupManager {}
impl BackupHandler for WindowsBackupManager {
fn win_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Config.get().ok_or(BackupError::NotFound)?)
}
fn win_local_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::DataLocal.get().ok_or(BackupError::NotFound)?)
}
fn win_local_app_data_low_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::DataLocalLow.get().ok_or(BackupError::NotFound)?)
}
fn win_dir_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(PathBuf::from_str("C:/Windows").unwrap())
}
fn win_documents_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Document.get().ok_or(BackupError::NotFound)?)
}
fn win_program_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(PathBuf::from_str("C:/ProgramData").unwrap())
}
fn win_public_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result<PathBuf, BackupError> {
Ok(CommonPath::Public.get().ok_or(BackupError::NotFound)?)
}
}
pub struct MacBackupManager {}
impl BackupHandler for MacBackupManager {}

View File

@ -1,7 +1,6 @@
use database::platform::Platform;
use crate::process::process_manager::Platform;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Condition {
Os(Platform),
Other
Os(Platform)
}

Some files were not shown because too many files have changed in this diff Show More