Compare commits

...

15 Commits

Author SHA1 Message Date
43b56462d6 feat: more refactoring (broken) 2025-09-16 15:09:43 +10:00
ab219670dc fix: cleanup dependencies 2025-09-14 09:27:49 +10:00
c1beef380e refactor: into rust workspaces 2025-09-14 09:19:03 +10:00
3f18d15d39 Collections & download stability, UI (#130)
* feat: different local path in dev #73

* feat: better error output for downloads

* feat: collections in library view

* feat: improve download manager reliability

* feat: new download UI, more stable downloads

* fix: clippy

* fix: only show admin link if user is admin

* feat: check for libs before building
2025-09-07 15:57:06 +10:00
97b5cd5e78 Native model fixes (#137)
* fix: Fix native_model from requirements and  add version requirements for models

Signed-off-by: quexeky <git@quexeky.dev>

* fix: Use Drop-OSS/native_model

Signed-off-by: quexeky <git@quexeky.dev>

* chore: Bump version to include logging

(Albeit, logging occurs before we initialise the logger, but oh well)

Signed-off-by: quexeky <git@quexeky.dev>

* chore: Make clippy happy

Signed-off-by: quexeky <git@quexeky.dev>

---------

Signed-off-by: quexeky <git@quexeky.dev>
2025-09-05 15:31:28 +10:00
7e70a17a43 Bump version to v0.3.3 2025-08-28 18:23:12 +10:00
8d61a68b8a Add placeholders to unfinished pages (#126)
* feat: add placeholders for community & news pages

* feat: add placeholder to interface in settings menu
2025-08-28 18:22:33 +10:00
44a1be6991 Fix for multi-version downloads (#125)
* fix: multi version downloads

* fix: remove debug utils

* fix: clippy
2025-08-28 18:05:05 +10:00
4f5fccf0c1 Add umu-run discovery (#122)
Signed-off-by: quexeky <git@quexeky.dev>
2025-08-28 18:05:05 +10:00
5eef2bf60f Fix Tauri builds (#119)
* fix: attempt to use local user keychain

* chore: tmp disable non-macos builds

* fix: windows process fix patch

* fix: re-enable windows

* fix: remove sudo

* fix: tmp disable windows again

* fix: windows build again

* chore: re-disable windows

* fix: pin to macos 14

* fix: re-enable other builds
2025-08-15 23:55:34 +10:00
ec6294b8e7 Fix #117 (#118)
* fix: version data not being attached to process manager push

* fix: clippy
2025-08-15 23:02:01 +10:00
17c375bcab UI & error fixes & QoL (#116)
* fix: use Arc<Error> instead of just ErrorKind

* fix: game status updates for UI

* fix: missing game version on push_game_update calls

* feat: wait if library load takes <300ms

* fix: clippy
2025-08-15 22:56:49 +10:00
cb55ac2bf5 Fix platform builds 2025-08-12 15:08:50 +10:00
e11db851a5 fix: #92 (#115) 2025-08-11 14:37:46 +10:00
16365713cf v2 download API and fixes (#112)
* fix: potential download fixes

* fix: show installed games not on remote

* fix: more download_logic error handling

* partial: move to async

* feat: interactivity improvements

* feat: v2 download API

* fix: download seek offsets

* fix: clippy

* fix: apply clippy suggestion

* fix: performance improvements starting up download

* fix: finished bucket file

* fix: ui tweaks and fixes

* fix: revert version to 0.3.2

* fix: clippy
2025-08-09 15:50:21 +10:00
106 changed files with 3533 additions and 3042 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
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
# 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.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
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
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

View File

@ -21,6 +21,13 @@ async function spawn(exec, opts) {
});
}
const expectedLibs = ["drop-base/package.json"];
for (const lib of expectedLibs) {
const path = `./libs/${lib}`;
if (!fs.existsSync(path)) throw `Missing "${expectedLibs}". Run "git submodule update --init --recursive"`;
}
const views = fs.readdirSync(".").filter((view) => {
const expectedPath = `./${view}/package.json`;
return fs.existsSync(expectedPath);

View File

@ -1,5 +1,5 @@
<template>
<LoadingIndicator />
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout class="select-none w-screen h-screen">
<NuxtPage />
<ModalStack />

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-slot="{ active }">
<MenuItem v-if="state.user.admin" v-slot="{ active }">
<a
:href="adminUrl"
target="_blank"

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="flex flex-col h-full">
<div class="mb-3 inline-flex gap-x-2">
<div
class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
@ -20,7 +20,7 @@
/>
</div>
<button
@click="() => calculateGames(true)"
@click="() => calculateGames(true, true)"
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100"
>
<ArrowPathIcon class="size-4" />
@ -28,64 +28,125 @@
</div>
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
<NuxtLink
v-for="nav in filteredNavigation"
<Disclosure
as="div"
v-for="(nav, navIndex) in filteredNavigation"
:key="nav.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center py-2 px-3 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
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"
class="first:pt-0 last:pb-0"
v-slot="{ open }"
:default-open="nav.deft"
>
<div class="flex items-center w-full gap-x-3">
<div
class="flex-none transition-transform duration-300 hover:-rotate-2"
<dt>
<DisclosureButton
class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
>
<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>
<span class="text-sm font-semibold font-display">{{
nav.name
}}</span>
<span class="ml-6 flex h-7 items-center">
<PlusSmallIcon v-if="!open" class="size-6" aria-hidden="true" />
<MinusSmallIcon v-else class="size-6" aria-hidden="true" />
</span>
</DisclosureButton>
</dt>
<DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
<NuxtLink
v-for="item in nav.items"
:key="nav.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center px-1 py-1.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
currentNavigation == item.id
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: item.isInstalled.value
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
]"
:href="item.route"
>
<div class="flex items-center w-full gap-x-2">
<div
class="flex-none transition-transform duration-300 hover:-rotate-2"
>
<img
class="size-6 object-cover bg-zinc-900 rounded transition-all duration-300 shadow-sm"
:src="icons[item.id]"
alt=""
/>
</div>
<div class="inline-flex items-center gap-x-2">
<p
class="text-sm whitespace-nowrap font-display font-semibold"
>
{{ item.label }}
</p>
<p
class="truncate text-[10px] font-bold uppercase font-display"
:class="[
gameStatusTextStyle[games[item.id].status.value.type],
]"
>
{{ gameStatusText[games[item.id].status.value.type] }}
</p>
</div>
</div>
</NuxtLink>
</DisclosurePanel>
</Disclosure>
</TransitionGroup>
<div
v-if="loading"
class="h-full grow flex p-8 justify-center text-zinc-100"
>
<div 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 { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/vue/20/solid";
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
import {
ArrowPathIcon,
MagnifyingGlassIcon,
MinusSmallIcon,
PlusSmallIcon,
} from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import { GameStatusEnum, type Game, type GameStatus } from "~/types";
import {
GameStatusEnum,
type Collection as Collection,
type Game,
type GameStatus,
} from "~/types";
import { TransitionGroup } from "vue";
import { listen } from "@tauri-apps/api/event";
// Style information
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "text-green-500",
[GameStatusEnum.Downloading]: "text-blue-500",
[GameStatusEnum.Downloading]: "text-zinc-400",
[GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-green-500",
[GameStatusEnum.Remote]: "text-zinc-500",
[GameStatusEnum.Queued]: "text-blue-500",
[GameStatusEnum.Updating]: "text-blue-500",
[GameStatusEnum.Remote]: "text-zinc-700",
[GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-zinc-400",
[GameStatusEnum.Uninstalling]: "text-zinc-100",
[GameStatusEnum.SetupRequired]: "text-yellow-500",
[GameStatusEnum.PartiallyInstalled]: "text-gray-400",
@ -107,71 +168,117 @@ 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 rawGames: Ref<Game[], Game[]> = ref([]);
const collections: Ref<Collection[]> = ref([]);
async function calculateGames(clearAll = false) {
if (clearAll) rawGames.value = [];
async function calculateGames(clearAll = false, forceRefresh = false) {
if (clearAll) {
collections.value = [];
loading.value = true;
}
// If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out
const newGames = await invoke<typeof rawGames.value>("fetch_library");
for (const game of newGames) {
const newGames = await invoke<Game[]>("fetch_library", {
hardRefresh: forceRefresh,
});
const otherCollections = await invoke<Collection[]>("fetch_collections", {
hardRefresh: forceRefresh,
});
const allGames = [
...newGames,
...otherCollections
.map((e) => e.entries)
.flat()
.map((e) => e.game),
].filter((v, i, a) => a.indexOf(v) === i);
for (const game of allGames) {
if (games[game.id]) continue;
games[game.id] = await useGame(game.id);
}
for (const game of newGames) {
for (const game of allGames) {
if (icons[game.id]) continue;
icons[game.id] = await useObject(game.mIconObjectId);
}
rawGames.value = newGames;
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];
}
await calculateGames();
// 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);
});
const navigation = computed(() =>
rawGames.value.map((game) => {
const status = games[game.id].status;
collections.value.map((collection) => {
const items = collection.entries.map(({ game }) => {
const status = games[game.id].status;
const isInstalled = computed(
() =>
status.value.type == GameStatusEnum.Installed ||
status.value.type == GameStatusEnum.SetupRequired
);
const isInstalled = computed(
() => status.value.type != GameStatusEnum.Remote
);
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
isInstalled,
id: game.id,
const item = {
label: game.mName,
route: `/library/${game.id}`,
prefix: `/library/${game.id}`,
isInstalled,
id: game.id,
};
return item;
});
return {
id: collection.id,
name: collection.name,
deft: collection.isDefault,
items,
};
return item;
})
);
const { currentNavigation, recalculateNavigation } = useCurrentNavigationIndex(
navigation.value
);
const route = useRoute();
const currentNavigation = computed(() => {
return route.path.slice("/library/".length);
});
const filteredNavigation = computed(() => {
if (!searchQuery.value)
return navigation.value.map((e, i) => ({ ...e, index: i }));
const query = searchQuery.value.toLowerCase();
return navigation.value
.filter((nav) => nav.label.toLowerCase().includes(query))
.map((e, i) => ({ ...e, index: i }));
.map((c) => ({
...c,
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
}))
.filter((e) => e.items.length > 0);
});
listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = navigation.value[currentNavigation.value];
let oldNavigation = currentNavigation.value;
await calculateGames();
recalculateNavigation();
if (oldNavigation !== navigation.value[currentNavigation.value]) {
console.log("Triggered");
if (oldNavigation !== currentNavigation.value) {
router.push("/library");
}
});

View File

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

View File

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

View File

@ -43,6 +43,7 @@ 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,7 +1,7 @@
{
"name": "view",
"private": true,
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"scripts": {
"build": "nuxt generate",

25
main/pages/community.vue Normal file
View File

@ -0,0 +1,25 @@
<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,7 +243,10 @@
</div>
</Listbox>
</div>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
<div
v-else-if="versionOptions === null || versionOptions?.length == 0"
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" />
@ -256,6 +259,27 @@
</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"
@ -510,13 +534,13 @@ async function installFlow() {
installDirs.value = undefined;
try {
versionOptions.value = await invoke("fetch_game_verion_options", {
versionOptions.value = await invoke("fetch_game_version_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;
}
}

25
main/pages/news.vue Normal file
View File

@ -0,0 +1,25 @@
<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 text-blue-400 font-display items-left justify-center pl-2"
class="bg-zinc-900 z-10 w-32 flex flex-col gap-x-2 font-display items-left justify-center pl-2"
>
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}/s</span>
<span v-if="stats.time > 0" class="text-sm"
<span class="font-bold text-zinc-100">{{ formatKilobytes(stats.speed) }}B/s</span>
<span v-if="stats.time > 0" class="text-xs text-zinc-400"
>{{ formatTime(stats.time) }} left</span
>
</div>
<div class="absolute inset-0 h-full flex flex-row items-end justify-end">
<div class="absolute inset-0 h-full flex flex-row items-end justify-end space-x-[1px]">
<div
v-for="bar in speedHistory"
:style="{ height: `${(bar / speedMax) * 100}%` }"
class="w-[8px] bg-blue-600/40"
class="w-[3px] bg-blue-600 rounded-t-full"
/>
</div>
</div>
@ -62,9 +62,9 @@
class="mt-2 inline-flex items-center gap-x-1 text-zinc-400 text-sm font-display"
><span class="text-zinc-300">{{
formatKilobytes(element.current / 1000)
}}</span>
}}B</span>
/
<span class="">{{ formatKilobytes(element.max / 1000) }}</span
<span class="">{{ formatKilobytes(element.max / 1000) }}B</span
><ServerIcon class="size-5"
/></span>
</div>
@ -91,7 +91,7 @@
<script setup lang="ts">
import { ServerIcon, XMarkIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import { GameStatusEnum, type DownloadableMetadata, type Game, type GameStatus } from "~/types";
import { type DownloadableMetadata, type Game, type GameStatus } from "~/types";
// const actionNames = {
// [GameStatusEnum.Downloading]: "downloading",
@ -105,12 +105,12 @@ window.addEventListener("resize", (event) => {
const queue = useQueueState();
const stats = useStatsState();
const speedHistory = useState<Array<number>>(() => []);
const speedHistoryMax = computed(() => windowWidth.value / 8);
const speedHistory = useDownloadHistory();
const speedHistoryMax = computed(() => windowWidth.value / 4);
const speedMax = computed(
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.3
() => speedHistory.value.reduce((a, b) => (a > b ? a : b)) * 1.1
);
const previousGameId = ref<string | undefined>();
const previousGameId = useState<string | undefined>('previous_game');
const games: Ref<{
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
@ -122,14 +122,15 @@ function resetHistoryGraph() {
}
function checkReset(v: QueueState) {
const currentGame = v.queue.at(0)?.meta.id;
// If we don't have a game
if (!currentGame) return;
// If we're finished
if (!currentGame && previousGameId.value) {
previousGameId.value = undefined;
resetHistoryGraph();
return;
}
// If we don't have a game
if (!currentGame) return;
// If we started a new download
if (currentGame && !previousGameId.value) {
previousGameId.value = currentGame;
@ -149,9 +150,10 @@ watch(queue, (v) => {
});
watch(stats, (v) => {
if(v.speed == 0) return;
const newLength = speedHistory.value.push(v.speed);
if (newLength > speedHistoryMax.value) {
speedHistory.value.splice(0, 1);
speedHistory.value.splice(0, newLength - speedHistoryMax.value);
}
checkReset(queue.value);
});
@ -183,7 +185,7 @@ async function cancelGame(meta: DownloadableMetadata) {
}
function formatKilobytes(bytes: number): string {
const units = ["KB", "MB", "GB", "TB", "PB"];
const units = ["K", "M", "G", "T", "P"];
let value = bytes;
let unitIndex = 0;
const scalar = 1000;

View File

@ -1,7 +1,23 @@
<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

@ -37,6 +37,13 @@ export type Game = {
mImageCarouselObjectIds: string[];
};
export type Collection = {
id: string;
name: string;
isDefault: boolean;
entries: Array<{ gameId: string; game: Game }>;
};
export type GameVersion = {
launchCommandTemplate: string;
};

1499
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,118 +1,101 @@
[package]
name = "drop-app"
version = "0.3.1"
description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"]
version = "0.3.3"
# authors = ["Drop OSS"]
edition = "2024"
description = "The client application for the open-source, self-hosted game distribution platform Drop"
[workspace]
resolver = "3"
members = ["drop-consts",
"drop-database",
"drop-downloads",
"drop-errors", "drop-library",
"drop-native-library",
"drop-process",
"drop-remote",
]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[lib]
crate-type = ["cdylib", "rlib", "staticlib"]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "drop_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
rustflags = ["-C", "target-feature=+aes,+sse2"]
[build-dependencies]
tauri-build = { version = "2.0.0", features = [] }
# rustflags = ["-C", "target-feature=+aes,+sse2"]
[dependencies]
tauri-plugin-shell = "2.2.1"
serde_json = "1"
rayon = "1.10.0"
webbrowser = "1.0.2"
url = "2.5.2"
tauri-plugin-deep-link = "2"
log = "0.4.22"
hex = "0.4.3"
tauri-plugin-dialog = "2"
http = "1.1.0"
urlencoding = "2.1.3"
md5 = "0.7.0"
chrono = "0.4.38"
tauri-plugin-os = "2"
boxcar = "0.2.7"
umu-wrapper-lib = "0.1.0"
tauri-plugin-autostart = "2.0.0"
shared_child = "1.0.1"
serde_with = "3.12.0"
slice-deque = "0.3.0"
throttle_my_fn = "0.2.6"
parking_lot = "0.12.3"
atomic-instant-full = "0.1.0"
cacache = "13.1.0"
http-serde = "2.1.1"
reqwest-middleware = "0.4.0"
reqwest-middleware-cache = "0.1.1"
deranged = "=0.4.0"
droplet-rs = "0.7.3"
gethostname = "1.0.1"
zstd = "0.13.3"
tar = "0.4.44"
rand = "0.9.1"
regex = "1.11.1"
tempfile = "3.19.1"
schemars = "0.8.22"
sha1 = "0.10.6"
dirs = "6.0.0"
whoami = "1.6.0"
filetime = "0.2.25"
walkdir = "2.5.0"
known-folders = "1.2.0"
native_model = { version = "0.6.1", features = ["rmp_serde_1_3"] }
tauri-plugin-opener = "2.4.0"
bitcode = "0.6.6"
reqwest-websocket = "0.5.0"
drop-database = { path = "./drop-database" }
drop-downloads = { path = "./drop-downloads" }
drop-errors = { path = "./drop-errors" }
drop-native-library = { path = "./drop-native-library" }
drop-process = { path = "./drop-process" }
drop-remote = { path = "./drop-remote" }
futures-lite = "2.6.0"
page_size = "0.6.0"
sysinfo = "0.36.1"
humansize = "2.1.3"
# tailscale = { path = "./tailscale" }
[dependencies.dynfmt]
version = "0.1.5"
features = ["curly"]
[dependencies.tauri]
version = "2.7.0"
features = ["protocol-asset", "tray-icon"]
[dependencies.tokio]
version = "1.40.0"
features = ["rt", "tokio-macros", "signal"]
hex = "0.4.3"
http = "1.1.0"
known-folders = "1.2.0"
log = "0.4.22"
md5 = "0.7.0"
rayon = "1.10.0"
regex = "1.11.1"
reqwest-websocket = "0.5.0"
serde_json = "1"
tar = "0.4.44"
tauri = { version = "2.7.0", features = ["protocol-asset", "tray-icon"] }
tauri-plugin-autostart = "2.0.0"
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2.4.0"
tauri-plugin-os = "2"
tauri-plugin-shell = "2.2.1"
tempfile = "3.19.1"
url = "2.5.2"
webbrowser = "1.0.2"
whoami = "1.6.0"
zstd = "0.13.3"
[dependencies.log4rs]
version = "1.3.0"
features = ["console_appender", "file_appender"]
[dependencies.reqwest]
version = "0.12.22"
default-features = false
features = [
"blocking",
"http2",
"json",
"native-tls-alpn",
"rustls-tls",
"rustls-tls-native-roots",
"stream",
]
[dependencies.rustix]
version = "0.38.37"
features = ["fs"]
[dependencies.uuid]
version = "1.10.0"
features = ["v4", "fast-rng", "macro-diagnostics"]
[dependencies.rustbreak]
version = "2"
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
[dependencies.reqwest]
version = "0.12"
default-features = false
features = ["json", "http2", "blocking", "rustls-tls", "native-tls-alpn", "rustls-tls-webpki-roots"]
[dependencies.serde]
version = "1"
features = ["derive", "rc"]
[dependencies.uuid]
version = "1.10.0"
features = ["fast-rng", "macro-diagnostics", "v4"]
[build-dependencies]
tauri-build = { version = "2.0.0", features = [] }
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
[profile.release]
lto = true
panic = "abort"
codegen-units = 1
panic = 'abort'

View File

@ -0,0 +1,7 @@
[package]
name = "drop-consts"
version = "0.1.0"
edition = "2024"
[dependencies]
dirs = "6.0.0"

View File

@ -0,0 +1,15 @@
use std::{
path::PathBuf,
sync::{Arc, LazyLock},
};
#[cfg(not(debug_assertions))]
static DATA_ROOT_PREFIX: &'static str = "drop";
#[cfg(debug_assertions)]
static DATA_ROOT_PREFIX: &str = "drop-debug";
pub static DATA_ROOT_DIR: LazyLock<&'static PathBuf> =
LazyLock::new(|| Box::leak(Box::new(dirs::data_dir().unwrap().join(DATA_ROOT_PREFIX))));
pub static CACHE_DIR: LazyLock<&'static PathBuf> =
LazyLock::new(|| Box::leak(Box::new(DATA_ROOT_DIR.join("cache"))));

View File

@ -0,0 +1,21 @@
[package]
name = "drop-database"
version = "0.1.0"
edition = "2024"
[dependencies]
bitcode = "0.6.7"
chrono = "0.4.42"
drop-consts = { path = "../drop-consts" }
drop-library = { path = "../drop-library" }
drop-native-library = { path = "../drop-native-library" }
log = "0.4.28"
native_model = { git = "https://github.com/Drop-OSS/native_model.git", version = "0.6.4", features = [
"rmp_serde_1_3",
] }
rustbreak = "2.0.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_with = "3.14.0"
url = "2.5.7"
whoami = "1.6.1"

View File

@ -7,19 +7,15 @@ use std::{
};
use chrono::Utc;
use drop_consts::DATA_ROOT_DIR;
use log::{debug, error, info, warn};
use native_model::{Decode, Encode};
use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError};
use serde::{Serialize, de::DeserializeOwned};
use url::Url;
use crate::DB;
use super::models::data::Database;
pub static DATA_ROOT_DIR: LazyLock<Arc<PathBuf>> =
LazyLock::new(|| Arc::new(dirs::data_dir().unwrap().join("drop")));
// Custom JSON serializer to support everything we need
#[derive(Debug, Default, Clone)]
pub struct DropDatabaseSerializer;
@ -28,16 +24,15 @@ impl<T: native_model::Model + Serialize + DeserializeOwned> DeSerializer<T>
for DropDatabaseSerializer
{
fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> {
native_model::rmp_serde_1_3::RmpSerde::encode(val)
.map_err(|e| DeSerError::Internal(e.to_string()))
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 = native_model::rmp_serde_1_3::RmpSerde::decode(buf)
.map_err(|e| DeSerError::Internal(e.to_string()))?;
.map_err(|e| rustbreak::error::DeSerError::Internal(e.to_string()))?;
let (val, _version) =
native_model::decode(buf).map_err(|e| DeSerError::Internal(e.to_string()))?;
Ok(val)
}
}
@ -47,8 +42,6 @@ pub type DatabaseInterface =
pub trait DatabaseImpls {
fn set_up_database() -> DatabaseInterface;
fn database_is_set_up(&self) -> bool;
fn fetch_base_url(&self) -> Url;
}
impl DatabaseImpls for DatabaseInterface {
fn set_up_database() -> DatabaseInterface {
@ -73,7 +66,7 @@ impl DatabaseImpls for DatabaseInterface {
Err(e) => handle_invalid_database(e, db_path, games_base_dir, cache_dir),
}
} else {
let default = Database::new(games_base_dir, None, cache_dir);
let default = Database::new(games_base_dir, None);
debug!(
"Creating database at path {}",
db_path.as_os_str().to_str().unwrap()
@ -81,15 +74,6 @@ impl DatabaseImpls for DatabaseInterface {
PathDatabase::create_at_path(db_path, default).expect("Database could not be created")
}
}
fn database_is_set_up(&self) -> bool {
!self.borrow_data().unwrap().base_url.is_empty()
}
fn fetch_base_url(&self) -> Url {
let handle = self.borrow_data().unwrap();
Url::parse(&handle.base_url).unwrap()
}
}
// TODO: Make the error relelvant rather than just assume that it's a Deserialize error
@ -112,15 +96,14 @@ fn handle_invalid_database(
let db = Database::new(
games_base_dir.into_os_string().into_string().unwrap(),
Some(new_path),
cache_dir,
);
PathDatabase::create_at_path(db_path, db).expect("Database could not be created")
}
// To automatically save the database upon drop
pub struct DBRead<'a>(RwLockReadGuard<'a, Database>);
pub struct DBWrite<'a>(ManuallyDrop<RwLockWriteGuard<'a, Database>>);
pub struct DBRead<'a>(pub(crate) RwLockReadGuard<'a, Database>);
pub struct DBWrite<'a>(pub(crate) ManuallyDrop<RwLockWriteGuard<'a, Database>>);
impl<'a> Deref for DBWrite<'a> {
type Target = Database;
@ -155,23 +138,3 @@ impl Drop for DBWrite<'_> {
}
}
}
pub fn borrow_db_checked<'a>() -> DBRead<'a> {
match DB.borrow_data() {
Ok(data) => DBRead(data),
Err(e) => {
error!("database borrow failed with error {e}");
panic!("database borrow failed with error {e}");
}
}
}
pub fn borrow_db_mut_checked<'a>() -> DBWrite<'a> {
match DB.borrow_data_mut() {
Ok(data) => DBWrite(ManuallyDrop::new(data)),
Err(e) => {
error!("database borrow mut failed with error {e}");
panic!("database borrow mut failed with error {e}");
}
}
}

View File

@ -9,7 +9,7 @@ pub type DropData = v1::DropData;
pub static DROP_DATA_PATH: &str = ".dropdata";
pub mod v1 {
mod v1 {
use std::{collections::HashMap, path::PathBuf, sync::Mutex};
use native_model::native_model;
@ -76,14 +76,6 @@ impl DropData {
pub fn set_context(&self, context: String, state: bool) {
self.contexts.lock().unwrap().entry(context).insert_entry(state);
}
pub fn get_completed_contexts(&self) -> Vec<String> {
self.contexts
.lock()
.unwrap()
.iter()
.filter_map(|x| if *x.1 { Some(x.0.clone()) } else { None })
.collect()
}
pub fn get_contexts(&self) -> HashMap<String, bool> {
self.contexts.lock().unwrap().clone()
}

View File

@ -0,0 +1,34 @@
use std::{mem::ManuallyDrop, sync::LazyLock};
use log::error;
use crate::db::{DBRead, DBWrite, DatabaseImpls, DatabaseInterface};
pub mod db;
pub mod debug;
pub mod models;
pub mod process;
pub mod runtime_models;
pub mod drop_data;
pub static DB: LazyLock<DatabaseInterface> = LazyLock::new(DatabaseInterface::set_up_database);
pub fn borrow_db_checked<'a>() -> DBRead<'a> {
match DB.borrow_data() {
Ok(data) => DBRead(data),
Err(e) => {
error!("database borrow failed with error {e}");
panic!("database borrow failed with error {e}");
}
}
}
pub fn borrow_db_mut_checked<'a>() -> DBWrite<'a> {
match DB.borrow_data_mut() {
Ok(data) => DBWrite(ManuallyDrop::new(data)),
Err(e) => {
error!("database borrow mut failed with error {e}");
panic!("database borrow mut failed with error {e}");
}
}
}

View File

@ -1,36 +1,47 @@
/**
* NEXT BREAKING CHANGE
*
* UPDATE DATABASE TO USE RPMSERDENAMED
*
* WE CAN'T DELETE ANY FIELDS
*/
pub mod data {
use std::path::PathBuf;
use std::{hash::Hash, path::PathBuf};
use native_model::native_model;
use serde::{Deserialize, Serialize};
// NOTE: Within each version, you should NEVER use these types.
// Declare it using the actual version that it is from, i.e. v1::Settings rather than just Settings from here
pub type GameVersion = v1::GameVersion;
pub type Database = v3::Database;
pub type Database = v4::Database;
pub type Settings = v1::Settings;
pub type DatabaseAuth = v1::DatabaseAuth;
pub type GameDownloadStatus = v2::GameDownloadStatus;
pub type ApplicationTransientStatus = v1::ApplicationTransientStatus;
/**
* Need to be universally accessible by the ID, and the version is just a couple sprinkles on top
*/
pub type DownloadableMetadata = v1::DownloadableMetadata;
pub type DownloadType = v1::DownloadType;
pub type DatabaseApplications = v2::DatabaseApplications;
pub type DatabaseCompatInfo = v2::DatabaseCompatInfo;
pub type DatabaseApplications = v4::DatabaseApplications;
// pub type DatabaseCompatInfo = v2::DatabaseCompatInfo;
use std::collections::HashMap;
pub mod v1 {
use crate::process::process_manager::Platform;
impl PartialEq for DownloadableMetadata {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.download_type == other.download_type
}
}
impl Hash for DownloadableMetadata {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
self.download_type.hash(state);
}
}
mod v1 {
use serde_with::serde_as;
use std::{collections::HashMap, path::PathBuf};
use crate::process::Platform;
use super::{Deserialize, Serialize, native_model};
fn default_template() -> String {
@ -116,6 +127,7 @@ pub mod data {
// Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize, Deserialize, Debug)]
pub enum ApplicationTransientStatus {
Queued { version_name: String },
Downloading { version_name: String },
Uninstalling {},
Updating { version_name: String },
@ -144,7 +156,7 @@ pub mod data {
}
#[native_model(id = 7, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone)]
#[derive(Debug, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DownloadableMetadata {
pub id: String,
@ -174,22 +186,21 @@ pub mod data {
}
}
pub mod v2 {
mod v2 {
use std::{collections::HashMap, path::PathBuf};
use serde_with::serde_as;
use super::{
ApplicationTransientStatus, DatabaseAuth, Deserialize, DownloadableMetadata,
GameVersion, Serialize, Settings, native_model, v1,
};
use crate::runtime_models::Game;
#[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
use super::{Deserialize, Serialize, native_model, v1};
#[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::Database)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub settings: v1::Settings,
pub auth: Option<v1::DatabaseAuth>,
pub base_url: String,
pub applications: v1::DatabaseApplications,
#[serde(skip)]
@ -198,7 +209,7 @@ pub mod data {
pub compat_info: Option<DatabaseCompatInfo>,
}
#[native_model(id = 8, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
#[native_model(id = 9, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct DatabaseCompatInfo {
@ -221,7 +232,7 @@ pub mod data {
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize, Debug)]
#[serde(tag = "type")]
#[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
#[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::GameDownloadStatus)]
pub enum GameDownloadStatus {
Remote {},
SetupRequired {
@ -261,16 +272,16 @@ pub mod data {
#[serde_as]
#[derive(Serialize, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)]
#[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from=v1::DatabaseApplications)]
pub struct DatabaseApplications {
pub install_dirs: Vec<PathBuf>,
// Guaranteed to exist if the game also exists in the app state map
pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,
pub game_versions: HashMap<String, HashMap<String, v1::GameVersion>>,
pub installed_game_version: HashMap<String, v1::DownloadableMetadata>,
#[serde(skip)]
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>,
pub transient_statuses:
HashMap<v1::DownloadableMetadata, v1::ApplicationTransientStatus>,
}
impl From<v1::DatabaseApplications> for DatabaseApplications {
fn from(value: v1::DatabaseApplications) -> Self {
@ -291,22 +302,19 @@ pub mod data {
mod v3 {
use std::path::PathBuf;
use super::{
DatabaseApplications, DatabaseAuth, DatabaseCompatInfo, Deserialize, Serialize,
Settings, native_model, v2,
};
#[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde)]
use super::{Deserialize, Serialize, native_model, v1, v2};
#[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde, from = v2::Database)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub settings: v1::Settings,
pub auth: Option<v1::DatabaseAuth>,
pub base_url: String,
pub applications: DatabaseApplications,
pub applications: v2::DatabaseApplications,
#[serde(skip)]
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
pub compat_info: Option<DatabaseCompatInfo>,
pub compat_info: Option<v2::DatabaseCompatInfo>,
}
impl From<v2::Database> for Database {
@ -324,26 +332,72 @@ pub mod data {
}
}
mod v4 {
use std::{collections::HashMap, path::PathBuf};
use drop_library::libraries::LibraryProviderIdentifier;
use drop_native_library::impls::DropNativeLibraryProvider;
use serde_with::serde_as;
use crate::models::data::v3;
use super::{Deserialize, Serialize, native_model, v1, v2};
#[derive(Serialize, Deserialize, Clone)]
pub enum Library {
NativeLibrary(DropNativeLibraryProvider),
}
#[serde_as]
#[derive(Serialize, Deserialize, Default, Clone)]
#[serde(rename_all = "camelCase")]
#[native_model(id = 3, version = 4, with = native_model::rmp_serde_1_3::RmpSerde, from=v2::DatabaseApplications)]
pub struct DatabaseApplications {
pub install_dirs: Vec<PathBuf>,
pub libraries: HashMap<LibraryProviderIdentifier, Library>,
#[serde(skip)]
pub transient_statuses:
HashMap<v1::DownloadableMetadata, v1::ApplicationTransientStatus>,
}
impl From<v2::DatabaseApplications> for DatabaseApplications {
fn from(value: v2::DatabaseApplications) -> Self {
todo!()
}
}
#[native_model(id = 1, version = 4, with = native_model::rmp_serde_1_3::RmpSerde, from = v3::Database)]
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct Database {
#[serde(default)]
pub settings: v1::Settings,
pub drop_applications: DatabaseApplications,
#[serde(skip)]
pub prev_database: Option<PathBuf>,
}
impl From<v3::Database> for Database {
fn from(value: v3::Database) -> Self {
Database {
settings: value.settings,
drop_applications: value.applications.into(),
prev_database: value.prev_database,
}
}
}
}
impl Database {
pub fn new<T: Into<PathBuf>>(
games_base_dir: T,
prev_database: Option<PathBuf>,
cache_dir: PathBuf,
) -> Self {
Self {
applications: DatabaseApplications {
drop_applications: DatabaseApplications {
install_dirs: vec![games_base_dir.into()],
game_statuses: HashMap::new(),
game_versions: HashMap::new(),
installed_game_version: HashMap::new(),
libraries: HashMap::new(),
transient_statuses: HashMap::new(),
},
prev_database,
base_url: String::new(),
auth: None,
settings: Settings::default(),
cache_dir,
compat_info: None,
}
}
}

View File

@ -0,0 +1,46 @@
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,
_ => unimplemented!(),
}
}
}

View File

@ -0,0 +1,28 @@
use bitcode::{Decode, Encode};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct Game {
pub id: String,
m_name: String,
m_short_description: String,
m_description: String,
// mDevelopers
// mPublishers
m_icon_object_id: String,
m_banner_object_id: String,
m_cover_object_id: String,
m_image_library_object_ids: Vec<String>,
m_image_carousel_object_ids: Vec<String>,
}
#[derive(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

@ -0,0 +1,16 @@
[package]
name = "drop-downloads"
version = "0.1.0"
edition = "2024"
[dependencies]
atomic-instant-full = "0.1.0"
drop-database = { path = "../drop-database" }
drop-errors = { path = "../drop-errors" }
# can't depend, cycle
# drop-native-library = { path = "../drop-native-library" }
log = "0.4.22"
parking_lot = "0.12.4"
serde = "1.0.219"
tauri = { version = "2.7.0" }
throttle_my_fn = "0.2.6"

View File

@ -7,13 +7,13 @@ use std::{
thread::{JoinHandle, spawn},
};
use drop_database::models::data::DownloadableMetadata;
use drop_errors::application_download_error::ApplicationDownloadError;
use log::{debug, error, info, warn};
use tauri::{AppHandle, Emitter};
use crate::{
database::models::data::DownloadableMetadata,
error::application_download_error::ApplicationDownloadError,
games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent},
download_manager_frontend::DownloadStatus, events::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent}
};
use super::{
@ -29,43 +29,6 @@ use super::{
pub type DownloadAgent = Arc<Box<dyn Downloadable + Send + Sync>>;
pub type CurrentProgressObject = Arc<Mutex<Option<Arc<ProgressObject>>>>;
/*
Welcome to the download manager, the most overengineered, glorious piece of bullshit.
The download manager takes a queue of ids and their associated
DownloadAgents, and then, one-by-one, executes them. It provides an interface
to interact with the currently downloading agent, and manage the queue.
When the DownloadManager is initialised, it is designed to provide a reference
which can be used to provide some instructions (the DownloadManagerInterface),
but other than that, it runs without any sort of interruptions.
It does this by opening up two data structures. Primarily is the command_receiver,
and mpsc (multi-channel-single-producer) which allows commands to be sent from
the Interface, and queued up for the Manager to process.
These have been mapped in the DownloadManagerSignal docs.
The other way to interact with the DownloadManager is via the donwload_queue,
which is just a collection of ids which may be rearranged to suit
whichever download queue order is required.
+----------------------------------------------------------------------------+
| DO NOT ATTEMPT TO ADD OR REMOVE FROM THE QUEUE WITHOUT USING SIGNALS!! |
| THIS WILL CAUSE A DESYNC BETWEEN THE DOWNLOAD AGENT REGISTRY AND THE QUEUE |
| WHICH HAS NOT BEEN ACCOUNTED FOR |
+----------------------------------------------------------------------------+
This download queue does not actually own any of the DownloadAgents. It is
simply an id-based reference system. The actual Agents are stored in the
download_agent_registry HashMap, as ordering is no issue here. This is why
appending or removing from the download_queue must be done via signals.
Behold, my madness - quexeky
*/
pub struct DownloadManagerBuilder {
download_agent_registry: HashMap<DownloadableMetadata, DownloadAgent>,
download_queue: Queue,
@ -75,7 +38,6 @@ pub struct DownloadManagerBuilder {
status: Arc<Mutex<DownloadManagerStatus>>,
app_handle: AppHandle,
current_download_agent: Option<DownloadAgent>, // Should be the only download agent in the map with the "Go" flag
current_download_thread: Mutex<Option<JoinHandle<()>>>,
active_control_flag: Option<DownloadThreadControl>,
}
@ -95,7 +57,6 @@ impl DownloadManagerBuilder {
progress: active_progress.clone(),
app_handle,
current_download_agent: None,
current_download_thread: Mutex::new(None),
active_control_flag: None,
};
@ -121,14 +82,18 @@ impl DownloadManagerBuilder {
fn cleanup_current_download(&mut self) {
self.active_control_flag = None;
*self.progress.lock().unwrap() = None;
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = None;
if let Some(unfinished_thread) = download_thread_lock.take()
&& !unfinished_thread.is_finished()
{
unfinished_thread.join().unwrap();
}
drop(download_thread_lock);
}
fn stop_and_wait_current_download(&self) {
fn stop_and_wait_current_download(&self) -> bool {
self.set_status(DownloadManagerStatus::Paused);
if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop);
@ -136,8 +101,10 @@ impl DownloadManagerBuilder {
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
return current_download_thread.join().is_ok();
};
true
}
fn manage_queue(mut self) -> Result<(), ()> {
@ -190,7 +157,7 @@ impl DownloadManagerBuilder {
return;
}
download_agent.on_initialised(&self.app_handle);
download_agent.on_queued(&self.app_handle);
self.download_queue.append(meta.clone());
self.download_agent_registry.insert(meta, download_agent);
@ -209,23 +176,13 @@ impl DownloadManagerBuilder {
return;
}
if self.current_download_agent.is_some()
&& self.download_queue.read().front().unwrap()
== &self.current_download_agent.as_ref().unwrap().metadata()
{
debug!(
"Current download agent: {:?}",
self.current_download_agent.as_ref().unwrap().metadata()
);
return;
}
debug!("current download queue: {:?}", self.download_queue.read());
// Should always be Some if the above two statements keep going
let agent_data = self.download_queue.read().front().unwrap().clone();
info!("starting download for {agent_data:?}");
let agent_data = if let Some(agent_data) = self.download_queue.read().front() {
agent_data.clone()
} else {
return;
};
let download_agent = self
.download_agent_registry
@ -233,8 +190,22 @@ impl DownloadManagerBuilder {
.unwrap()
.clone();
let status = download_agent.status();
// This download is already going
if status != DownloadStatus::Queued {
return;
}
// Ensure all others are marked as queued
for agent in self.download_agent_registry.values() {
if agent.metadata() != agent_data && agent.status() != DownloadStatus::Queued {
agent.on_queued(&self.app_handle);
}
}
info!("starting download for {agent_data:?}");
self.active_control_flag = Some(download_agent.control_flag());
self.current_download_agent = Some(download_agent.clone());
let sender = self.sender.clone();
@ -254,12 +225,16 @@ impl DownloadManagerBuilder {
}
};
// If the download gets cancelled
// If the download gets canceled
// immediately return, on_cancelled gets called for us earlier
if !download_result {
return;
}
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
return;
}
let validate_result = match download_agent.validate(&app_handle) {
Ok(v) => v,
Err(e) => {
@ -274,6 +249,10 @@ impl DownloadManagerBuilder {
}
};
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
return;
}
if validate_result {
download_agent.on_complete(&app_handle);
sender
@ -299,8 +278,8 @@ impl DownloadManagerBuilder {
}
fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
debug!("got signal Completed");
if let Some(interface) = &self.current_download_agent
&& interface.metadata() == meta
if let Some(interface) = self.download_queue.read().front()
&& interface == &meta
{
self.remove_and_cleanup_front_download(&meta);
}
@ -310,43 +289,37 @@ impl DownloadManagerBuilder {
}
fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
debug!("got signal Error");
if let Some(current_agent) = self.current_download_agent.clone() {
if let Some(metadata) = self.download_queue.read().front()
&& let Some(current_agent) = self.download_agent_registry.get(metadata)
{
current_agent.on_error(&self.app_handle, &error);
self.stop_and_wait_current_download();
self.remove_and_cleanup_front_download(&current_agent.metadata());
self.remove_and_cleanup_front_download(metadata);
}
self.push_ui_queue_update();
self.set_status(DownloadManagerStatus::Error);
}
fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) {
debug!("got signal Cancel");
if let Some(current_download) = &self.current_download_agent {
if &current_download.metadata() == meta {
self.set_status(DownloadManagerStatus::Paused);
current_download.on_cancelled(&self.app_handle);
self.stop_and_wait_current_download();
// If the current download is the one we're tryna cancel
if let Some(current_metadata) = self.download_queue.read().front()
&& current_metadata == meta
&& let Some(current_download) = self.download_agent_registry.get(current_metadata)
{
self.set_status(DownloadManagerStatus::Paused);
current_download.on_cancelled(&self.app_handle);
self.stop_and_wait_current_download();
self.download_queue.pop_front();
self.download_queue.pop_front();
self.cleanup_current_download();
debug!("current download queue: {:?}", self.download_queue.read());
}
// TODO: Collapse these two into a single if statement somehow
else if let Some(download_agent) = self.download_agent_registry.get(meta) {
let index = self.download_queue.get_by_meta(meta);
if let Some(index) = index {
download_agent.on_cancelled(&self.app_handle);
let _ = self.download_queue.edit().remove(index).unwrap();
let removed = self.download_agent_registry.remove(meta);
debug!(
"removed {:?} from queue {:?}",
removed.map(|x| x.metadata()),
self.download_queue.read()
);
}
}
} else if let Some(download_agent) = self.download_agent_registry.get(meta) {
self.cleanup_current_download();
self.download_agent_registry.remove(meta);
debug!("current download queue: {:?}", self.download_queue.read());
}
// else just cancel it
else if let Some(download_agent) = self.download_agent_registry.get(meta) {
let index = self.download_queue.get_by_meta(meta);
if let Some(index) = index {
download_agent.on_cancelled(&self.app_handle);
@ -359,6 +332,7 @@ impl DownloadManagerBuilder {
);
}
}
self.sender.send(DownloadManagerSignal::Go).unwrap();
self.push_ui_queue_update();
}
fn push_ui_stats_update(&self, kbs: usize, time: usize) {

View File

@ -9,14 +9,11 @@ use std::{
thread::JoinHandle,
};
use drop_database::models::data::DownloadableMetadata;
use drop_errors::application_download_error::ApplicationDownloadError;
use log::{debug, info};
use serde::Serialize;
use crate::{
database::models::data::DownloadableMetadata,
error::application_download_error::ApplicationDownloadError,
};
use super::{
download_manager_builder::{CurrentProgressObject, DownloadAgent},
util::queue::Queue,
@ -62,7 +59,7 @@ impl Serialize for DownloadManagerStatus {
}
}
#[derive(Serialize, Clone, Debug)]
#[derive(Serialize, Clone, Debug, PartialEq)]
pub enum DownloadStatus {
Queued,
Downloading,

View File

@ -1,17 +1,20 @@
use std::sync::Arc;
use drop_database::models::data::DownloadableMetadata;
use drop_errors::application_download_error::ApplicationDownloadError;
use tauri::AppHandle;
use crate::{
database::models::data::DownloadableMetadata,
error::application_download_error::ApplicationDownloadError,
};
use super::{
download_manager_frontend::DownloadStatus,
util::{download_thread_control_flag::DownloadThreadControl, progress_object::ProgressObject},
};
/**
* Downloadables are responsible for managing their specific object's download state
* e.g, the GameDownloadAgent is responsible for pushing game updates
*
* But the download manager manages the queue state
*/
pub trait Downloadable: Send + Sync {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError>;
fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError>;
@ -20,7 +23,7 @@ pub trait Downloadable: Send + Sync {
fn control_flag(&self) -> DownloadThreadControl;
fn status(&self) -> DownloadStatus;
fn metadata(&self) -> DownloadableMetadata;
fn on_initialised(&self, app_handle: &AppHandle);
fn on_queued(&self, app_handle: &AppHandle);
fn on_error(&self, app_handle: &AppHandle, error: &ApplicationDownloadError);
fn on_complete(&self, app_handle: &AppHandle);
fn on_cancelled(&self, app_handle: &AppHandle);

View File

@ -0,0 +1,24 @@
use drop_database::models::data::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

@ -0,0 +1,7 @@
#![feature(duration_millis_float)]
pub mod download_manager_builder;
pub mod download_manager_frontend;
pub mod downloadable;
pub mod events;
pub mod util;

View File

@ -1,8 +1,8 @@
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
mpsc::Sender,
Arc, Mutex,
},
time::{Duration, Instant},
};
@ -10,7 +10,7 @@ use std::{
use atomic_instant_full::AtomicInstant;
use throttle_my_fn::throttle;
use crate::download_manager::download_manager_frontend::DownloadManagerSignal;
use crate::download_manager_frontend::DownloadManagerSignal;
use super::rolling_progress_updates::RollingProgressWindow;
@ -23,7 +23,7 @@ pub struct ProgressObject {
//last_update: Arc<RwLock<Instant>>,
last_update_time: Arc<AtomicInstant>,
bytes_last_update: Arc<AtomicUsize>,
rolling: RollingProgressWindow<250>,
rolling: RollingProgressWindow<1000>,
}
#[derive(Clone)]
@ -120,7 +120,7 @@ pub fn calculate_update(progress: &ProgressObject) {
let last_update_time = progress
.last_update_time
.swap(Instant::now(), Ordering::SeqCst);
let time_since_last_update = Instant::now().duration_since(last_update_time).as_millis();
let time_since_last_update = Instant::now().duration_since(last_update_time).as_millis_f64();
let current_bytes_downloaded = progress.sum();
let max = progress.get_max();
@ -128,17 +128,17 @@ pub fn calculate_update(progress: &ProgressObject) {
.bytes_last_update
.swap(current_bytes_downloaded, Ordering::Acquire);
let bytes_since_last_update = current_bytes_downloaded - bytes_at_last_update;
let bytes_since_last_update = current_bytes_downloaded.saturating_sub(bytes_at_last_update) as f64;
let kilobytes_per_second = bytes_since_last_update / (time_since_last_update as usize).max(1);
let kilobytes_per_second = bytes_since_last_update / time_since_last_update;
let bytes_remaining = max.saturating_sub(current_bytes_downloaded); // bytes
progress.update_window(kilobytes_per_second);
progress.update_window(kilobytes_per_second as usize);
push_update(progress, bytes_remaining);
}
#[throttle(1, Duration::from_millis(500))]
#[throttle(1, Duration::from_millis(250))]
pub fn push_update(progress: &ProgressObject, bytes_remaining: usize) {
let average_speed = progress.rolling.get_average();
let time_remaining = (bytes_remaining / 1000) / average_speed.max(1);

View File

@ -3,7 +3,7 @@ use std::{
sync::{Arc, Mutex, MutexGuard},
};
use crate::database::models::data::DownloadableMetadata;
use drop_database::models::data::DownloadableMetadata;
#[derive(Clone)]
pub struct Queue {

View File

@ -1,6 +1,6 @@
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
atomic::{AtomicUsize, Ordering},
};
#[derive(Clone)]
@ -22,17 +22,22 @@ impl<const S: usize> RollingProgressWindow<S> {
}
pub fn get_average(&self) -> usize {
let current = self.current.load(Ordering::SeqCst);
self.window
let valid = self
.window
.iter()
.enumerate()
.filter(|(i, _)| i < &current)
.map(|(_, x)| x.load(Ordering::Acquire))
.sum::<usize>()
/ S
.collect::<Vec<usize>>();
let amount = valid.len();
let sum = valid.into_iter().sum::<usize>();
sum / amount
}
pub fn reset(&self) {
self.window
.iter()
.for_each(|x| x.store(0, Ordering::Release));
self.current.store(0, Ordering::Release);
}
}

View File

@ -0,0 +1,14 @@
[package]
name = "drop-errors"
version = "0.1.0"
edition = "2024"
[dependencies]
http = "1.3.1"
humansize = "2.1.3"
reqwest = "0.12.23"
reqwest-websocket = "0.5.1"
serde = { version = "1.0.219", features = ["derive"] }
serde_with = "3.14.0"
tauri-plugin-opener = "2.5.0"
url = "2.5.7"

View File

@ -1,6 +1,6 @@
use std::{
fmt::{Display, Formatter},
io,
io, sync::Arc,
};
use serde_with::SerializeDisplay;
@ -11,17 +11,20 @@ use super::remote_access_error::RemoteAccessError;
// TODO: Rename / separate from downloads
#[derive(Debug, SerializeDisplay)]
pub enum ApplicationDownloadError {
NotInitialized,
Communication(RemoteAccessError),
DiskFull(u64, u64),
#[allow(dead_code)]
Checksum,
Lock,
IoError(io::ErrorKind),
IoError(Arc<io::Error>),
DownloadError,
}
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.",
@ -39,7 +42,7 @@ impl Display for ApplicationDownloadError {
ApplicationDownloadError::IoError(error) => write!(f, "io error: {error}"),
ApplicationDownloadError::DownloadError => write!(
f,
"download failed. See Download Manager status for specific error"
"Download failed. See Download Manager status for specific error"
),
}
}

View File

@ -2,7 +2,7 @@ use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DropServerError {
pub struct ServerError {
pub status_code: usize,
pub status_message: String,
// pub message: String,

View File

@ -8,7 +8,7 @@ use http::StatusCode;
use serde_with::SerializeDisplay;
use url::ParseError;
use super::drop_server_error::DropServerError;
use super::drop_server_error::ServerError;
#[derive(Debug, SerializeDisplay)]
pub enum RemoteAccessError {
@ -18,11 +18,12 @@ pub enum RemoteAccessError {
InvalidEndpoint,
HandshakeFailed(String),
GameNotFound(String),
InvalidResponse(DropServerError),
InvalidResponse(ServerError),
UnparseableResponse(String),
ManifestDownloadFailed(StatusCode, String),
OutOfSync,
Cache(std::io::Error),
CorruptedState,
}
impl Display for RemoteAccessError {
@ -81,6 +82,10 @@ impl Display for RemoteAccessError {
"server's and client's time are out of sync. Please ensure they are within at least 30 seconds of each other"
),
RemoteAccessError::Cache(error) => write!(f, "Cache Error: {error}"),
RemoteAccessError::CorruptedState => write!(
f,
"Drop encountered a corrupted internal state. Please report this to the developers, with details of reproduction."
),
}
}
}

View File

@ -0,0 +1,11 @@
[package]
name = "drop-library"
version = "0.1.0"
edition = "2024"
[dependencies]
drop-errors = { path = "../drop-errors" }
http = "*"
reqwest = { version = "*", default-features = false }
serde = { version = "*", default-features = false, features = ["derive"] }
tauri = "*"

View File

@ -0,0 +1,11 @@
pub enum DropLibraryError {
NetworkError(reqwest::Error),
ServerError(drop_errors::drop_server_error::ServerError),
Unconfigured,
}
impl From<reqwest::Error> for DropLibraryError {
fn from(value: reqwest::Error) -> Self {
DropLibraryError::NetworkError(value)
}
}

View File

@ -0,0 +1,30 @@
use crate::libraries::LibraryProviderIdentifier;
pub struct LibraryGamePreview {
pub library: LibraryProviderIdentifier,
pub internal_id: String,
pub name: String,
pub short_description: String,
pub icon: String,
}
pub struct LibraryGame {
pub library: LibraryProviderIdentifier,
pub internal_id: String,
pub name: String,
pub short_description: String,
pub md_description: String,
pub icon: String,
}
impl From<LibraryGame> for LibraryGamePreview {
fn from(value: LibraryGame) -> Self {
LibraryGamePreview {
library: value.library,
internal_id: value.internal_id,
name: value.name,
short_description: value.short_description,
icon: value.icon,
}
}
}

View File

@ -0,0 +1,3 @@
pub mod libraries;
pub mod game;
pub mod errors;

View File

@ -0,0 +1,76 @@
use std::{
fmt::Display,
hash::{DefaultHasher, Hash, Hasher},
};
use http::Request;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use tauri::UriSchemeResponder;
use crate::{
errors::DropLibraryError,
game::{LibraryGame, LibraryGamePreview},
};
#[derive(Clone, Serialize, Deserialize)]
pub struct LibraryProviderIdentifier {
internal_id: usize,
name: String,
}
impl PartialEq for LibraryProviderIdentifier {
fn eq(&self, other: &Self) -> bool {
self.internal_id == other.internal_id
}
}
impl Eq for LibraryProviderIdentifier {}
impl Hash for LibraryProviderIdentifier {
fn hash<H: Hasher>(&self, state: &mut H) {
self.internal_id.hash(state);
}
}
impl Display for LibraryProviderIdentifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.name)
}
}
impl LibraryProviderIdentifier {
pub fn str_hash(&self) -> String {
let mut hasher = DefaultHasher::new();
self.hash(&mut hasher);
hasher.finish().to_string()
}
}
pub struct LibraryFetchConfig {
pub hard_refresh: bool,
}
pub trait DropLibraryProvider: Serialize + DeserializeOwned + Sized {
fn build(identifier: LibraryProviderIdentifier) -> Self;
fn id(&self) -> &LibraryProviderIdentifier;
fn load_object(
&self,
request: Request<Vec<u8>>,
responder: UriSchemeResponder,
) -> impl Future<Output = Result<(), DropLibraryError>> + Send;
fn fetch_library(
&self,
config: &LibraryFetchConfig,
) -> impl Future<Output = Result<Vec<LibraryGamePreview>, DropLibraryError>> + Send;
fn fetch_game(
&self,
config: &LibraryFetchConfig,
) -> impl Future<Output = Result<LibraryGame, DropLibraryError>> + Send;
fn owns_game(&self, id: &LibraryProviderIdentifier) -> bool {
self.id().internal_id == id.internal_id
}
}

View File

@ -0,0 +1,14 @@
[package]
name = "drop-native-library"
version = "0.1.0"
edition = "2024"
[dependencies]
bitcode = "*"
drop-errors = { path = "../drop-errors" }
drop-library = { path = "../drop-library" }
drop-remote = { path = "../drop-remote" }
log = "*"
serde = { version = "*", features = ["derive"] }
tauri = "*"
url = "*"

View File

@ -1,10 +1,10 @@
use bitcode::{Decode, Encode};
// use drop_database::runtime_models::Game;
use serde::{Deserialize, Serialize};
use crate::games::library::Game;
pub type Collections = Vec<Collection>;
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct Collection {
id: String,
@ -14,7 +14,7 @@ pub struct Collection {
entries: Vec<CollectionObject>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct CollectionObject {
collection_id: String,

View File

@ -0,0 +1,11 @@
use drop_database::models::data::{ApplicationTransientStatus, GameDownloadStatus, GameVersion};
#[derive(serde::Serialize, Clone)]
pub struct GameUpdateEvent {
pub game_id: String,
pub status: (
Option<GameDownloadStatus>,
Option<ApplicationTransientStatus>,
),
pub version: Option<GameVersion>,
}

View File

@ -0,0 +1,50 @@
use drop_library::{
errors::DropLibraryError, game::{LibraryGame, LibraryGamePreview}, libraries::{DropLibraryProvider, LibraryFetchConfig, LibraryProviderIdentifier}
};
use drop_remote::{fetch_object::fetch_object, DropRemoteContext};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Serialize, Deserialize, Clone)]
pub struct DropNativeLibraryProvider {
identifier: LibraryProviderIdentifier,
context: Option<DropRemoteContext>,
}
impl DropNativeLibraryProvider {
pub fn configure(&mut self, base_url: Url) {
self.context = Some(DropRemoteContext::new(base_url));
}
}
impl DropLibraryProvider for DropNativeLibraryProvider {
fn build(identifier: LibraryProviderIdentifier) -> Self {
Self {
identifier,
context: None,
}
}
fn id(&self) -> &LibraryProviderIdentifier {
&self.identifier
}
async fn load_object(&self, request: tauri::http::Request<Vec<u8>>, responder: tauri::UriSchemeResponder) -> Result<(), DropLibraryError> {
let context = self.context.as_ref().ok_or(DropLibraryError::Unconfigured)?;
fetch_object(context, request, responder).await;
Ok(())
}
async fn fetch_library(
&self,
config: &LibraryFetchConfig
) -> Result<Vec<LibraryGamePreview>, DropLibraryError> {
todo!()
}
async fn fetch_game(&self, config: &LibraryFetchConfig) -> Result<LibraryGame, DropLibraryError> {
todo!()
}
}

View File

@ -0,0 +1,5 @@
//pub mod collections;
//pub mod library;
//pub mod state;
//pub mod events;
pub mod impls;

View File

@ -1,28 +1,34 @@
use std::fs::remove_dir_all;
use std::sync::Mutex;
use std::thread::spawn;
use drop_database::borrow_db_checked;
use drop_database::borrow_db_mut_checked;
use drop_database::models::data::ApplicationTransientStatus;
use drop_database::models::data::Database;
use drop_database::models::data::DownloadableMetadata;
use drop_database::models::data::GameDownloadStatus;
use drop_database::models::data::GameVersion;
use drop_database::runtime_models::Game;
use drop_errors::drop_server_error::ServerError;
use drop_errors::library_error::LibraryError;
use drop_errors::remote_access_error::RemoteAccessError;
use drop_remote::DropRemoteContext;
use drop_remote::auth::generate_authorization_header;
use drop_remote::cache::cache_object;
use drop_remote::cache::cache_object_db;
use drop_remote::cache::get_cached_object;
use drop_remote::cache::get_cached_object_db;
use drop_remote::requests::generate_url;
use drop_remote::utils::DROP_CLIENT_ASYNC;
use drop_remote::utils::DROP_CLIENT_SYNC;
use log::{debug, error, warn};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri::Emitter;
use tauri::Emitter as _;
use crate::AppState;
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
use crate::database::models::data::Database;
use crate::database::models::data::{
ApplicationTransientStatus, DownloadableMetadata, GameDownloadStatus, GameVersion,
};
use crate::download_manager::download_manager_frontend::DownloadStatus;
use crate::error::library_error::LibraryError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::state::{GameStatusManager, GameStatusWithTransient};
use crate::remote::auth::generate_authorization_header;
use crate::remote::cache::cache_object_db;
use crate::remote::cache::{cache_object, get_cached_object, get_cached_object_db};
use crate::remote::requests::make_request;
use crate::remote::utils::DROP_CLIENT_SYNC;
use bitcode::{Decode, Encode};
use crate::events::GameUpdateEvent;
use crate::state::GameStatusManager;
use crate::state::GameStatusWithTransient;
#[derive(Serialize, Deserialize, Debug)]
pub struct FetchGameStruct {
@ -31,76 +37,41 @@ pub struct FetchGameStruct {
version: Option<GameVersion>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct Game {
id: String,
m_name: String,
m_short_description: String,
m_description: String,
// mDevelopers
// mPublishers
m_icon_object_id: String,
m_banner_object_id: String,
m_cover_object_id: String,
m_image_library_object_ids: Vec<String>,
m_image_carousel_object_ids: Vec<String>,
}
#[derive(serde::Serialize, Clone)]
pub struct GameUpdateEvent {
pub game_id: String,
pub status: (
Option<GameDownloadStatus>,
Option<ApplicationTransientStatus>,
),
pub version: Option<GameVersion>,
}
#[derive(Serialize, Clone)]
pub struct QueueUpdateEventQueueData {
pub meta: DownloadableMetadata,
pub status: DownloadStatus,
pub progress: f64,
pub current: usize,
pub max: usize,
}
#[derive(serde::Serialize, Clone)]
pub struct QueueUpdateEvent {
pub queue: Vec<QueueUpdateEventQueueData>,
}
#[derive(serde::Serialize, Clone)]
pub struct StatsUpdateEvent {
pub speed: usize,
pub time: usize,
}
pub fn fetch_library_logic(
state: tauri::State<'_, Mutex<AppState>>,
pub async fn fetch_library_logic(
context: &DropRemoteContext,
hard_fresh: Option<bool>,
) -> Result<Vec<Game>, RemoteAccessError> {
let header = generate_authorization_header();
let do_hard_refresh = hard_fresh.unwrap_or(false);
if !do_hard_refresh && let Ok(library) = get_cached_object("library") {
return Ok(library);
}
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(&client, &["/api/v1/client/user/library"], &[], |f| {
f.header("Authorization", header)
})?
.send()?;
let client = DROP_CLIENT_ASYNC.clone();
let response = generate_url(context, &["/api/v1/client/user/library"], &[])?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header(context))
.send()
.await?;
if response.status() != 200 {
let err = response.json().unwrap();
let err = response.json().await.unwrap_or(ServerError {
status_code: 500,
status_message: "Invalid response from server.".to_owned(),
});
warn!("{err:?}");
return Err(RemoteAccessError::InvalidResponse(err));
}
let mut games: Vec<Game> = response.json()?;
let mut handle = state.lock().unwrap();
let mut games: Vec<Game> = response.json().await?;
let mut db_handle = borrow_db_mut_checked();
for game in &games {
handle.games.insert(game.id.clone(), game.clone());
db_handle
.applications
.games
.insert(game.id.clone(), game.clone());
if !db_handle.applications.game_statuses.contains_key(&game.id) {
db_handle
.applications
@ -116,7 +87,7 @@ pub fn fetch_library_logic(
}
// We should always have a cache of the object
// Pass db_handle because otherwise we get a gridlock
let game = match get_cached_object_db::<Game>(&meta.id.clone(), &db_handle) {
let game = match get_cached_object_db::<Game>(&meta.id.clone()) {
Ok(game) => game,
Err(err) => {
warn!(
@ -129,82 +100,96 @@ pub fn fetch_library_logic(
games.push(game);
}
drop(handle);
drop(db_handle);
cache_object("library", &games)?;
Ok(games)
}
pub fn fetch_library_logic_offline(
_state: tauri::State<'_, Mutex<AppState>>,
pub async fn fetch_library_logic_offline(
_hard_refresh: Option<bool>,
) -> Result<Vec<Game>, RemoteAccessError> {
let mut games: Vec<Game> = get_cached_object("library")?;
let db_handle = borrow_db_checked();
games.retain(|game| {
db_handle
.applications
.installed_game_version
.contains_key(&game.id)
matches!(
&db_handle
.applications
.game_statuses
.get(&game.id)
.unwrap_or(&GameDownloadStatus::Remote {}),
GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }
)
});
Ok(games)
}
pub fn fetch_game_logic(
pub async fn fetch_game_logic(
context: &DropRemoteContext,
id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
let mut state_handle = state.lock().unwrap();
let version = {
let db_lock = borrow_db_checked();
let db_lock = borrow_db_checked();
let metadata_option = db_lock.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => db_lock
.applications
.game_versions
.get(&metadata.id)
.map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap())
.cloned(),
};
let game = state_handle.games.get(&id);
if let Some(game) = game {
let status = GameStatusManager::fetch_state(&id, &db_lock);
let data = FetchGameStruct {
game: game.clone(),
status,
version,
let metadata_option = db_lock.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => db_lock
.applications
.game_versions
.get(&metadata.id)
.map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap())
.cloned(),
};
cache_object_db(&id, game, &db_lock)?;
let game = db_lock.applications.games.get(&id);
if let Some(game) = game {
let status = GameStatusManager::fetch_state(&id, &db_lock);
return Ok(data);
}
drop(db_lock);
let data = FetchGameStruct {
game: game.clone(),
status,
version,
};
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(&client, &["/api/v1/client/game/", &id], &[], |r| {
r.header("Authorization", generate_authorization_header())
})?
.send()?;
cache_object_db(&id, game, &db_lock)?;
return Ok(data);
}
version
};
let client = DROP_CLIENT_ASYNC.clone();
let response = generate_url(context, &["/api/v1/client/game/", &id], &[])?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header(context))
.send()
.await?;
if response.status() == 404 {
let offline_fetch = fetch_game_logic_offline(id.clone()).await;
if let Ok(fetch_data) = offline_fetch {
return Ok(fetch_data);
}
return Err(RemoteAccessError::GameNotFound(id));
}
if response.status() != 200 {
let err = response.json().unwrap();
let err = response.json().await.unwrap();
warn!("{err:?}");
return Err(RemoteAccessError::InvalidResponse(err));
}
let game: Game = response.json()?;
state_handle.games.insert(id.clone(), game.clone());
let game: Game = response.json().await?;
let mut db_handle = borrow_db_mut_checked();
db_handle
.applications
.games
.insert(id.clone(), game.clone());
db_handle
.applications
@ -227,24 +212,17 @@ pub fn fetch_game_logic(
Ok(data)
}
pub fn fetch_game_logic_offline(
id: String,
_state: tauri::State<'_, Mutex<AppState>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
pub async fn fetch_game_logic_offline(id: String) -> Result<FetchGameStruct, RemoteAccessError> {
let db_handle = borrow_db_checked();
let metadata_option = db_handle.applications.installed_game_version.get(&id);
let version = match metadata_option {
None => None,
Some(metadata) => Some(
db_handle
.applications
.game_versions
.get(&metadata.id)
.unwrap()
.get(metadata.version.as_ref().unwrap())
.unwrap()
.clone(),
),
Some(metadata) => db_handle
.applications
.game_versions
.get(&metadata.id)
.map(|v| v.get(metadata.version.as_ref().unwrap()).unwrap())
.cloned(),
};
let status = GameStatusManager::fetch_state(&id, &db_handle);
@ -259,40 +237,30 @@ pub fn fetch_game_logic_offline(
})
}
pub fn fetch_game_verion_options_logic(
pub async fn fetch_game_version_options_logic(
context: &DropRemoteContext,
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<Vec<GameVersion>, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let client = DROP_CLIENT_ASYNC.clone();
let response = make_request(
&client,
let response = generate_url(
context,
&["/api/v1/client/game/versions"],
&[("id", &game_id)],
|r| r.header("Authorization", generate_authorization_header()),
)?
.send()?;
)?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header(context))
.send()
.await?;
if response.status() != 200 {
let err = response.json().unwrap();
let err = response.json().await.unwrap();
warn!("{err:?}");
return Err(RemoteAccessError::InvalidResponse(err));
}
let data: Vec<GameVersion> = response.json()?;
let state_lock = state.lock().unwrap();
let process_manager_lock = state_lock.process_manager.lock().unwrap();
let data: Vec<GameVersion> = data
.into_iter()
.filter(|v| {
process_manager_lock
.valid_platform(&v.platform, &state_lock)
.unwrap()
})
.collect();
drop(process_manager_lock);
drop(state_lock);
let data: Vec<GameVersion> = response.json().await?;
Ok(data)
}
@ -346,8 +314,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
db_handle
.applications
.transient_statuses
.entry(meta.clone())
.and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {});
.insert(meta.clone(), ApplicationTransientStatus::Uninstalling {});
push_game_update(
app_handle,
@ -381,8 +348,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
db_handle
.applications
.transient_statuses
.entry(meta.clone())
.and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {});
.insert(meta.clone(), ApplicationTransientStatus::Uninstalling {});
drop(db_handle);
@ -400,8 +366,7 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
db_handle
.applications
.game_statuses
.entry(meta.id.clone())
.and_modify(|e| *e = GameDownloadStatus::Remote {});
.insert(meta.id.clone(), GameDownloadStatus::Remote {});
let _ = db_handle.applications.transient_statuses.remove(&meta);
push_game_update(
@ -413,8 +378,6 @@ pub fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle)
debug!("uninstalled game id {}", &meta.id);
app_handle.emit("update_library", ()).unwrap();
drop(db_handle);
}
});
} else {
@ -431,6 +394,7 @@ pub fn get_current_meta(game_id: &String) -> Option<DownloadableMetadata> {
}
pub fn on_game_complete(
context: &DropRemoteContext,
meta: &DownloadableMetadata,
install_dir: String,
app_handle: &AppHandle,
@ -440,19 +404,19 @@ pub fn on_game_complete(
return Err(RemoteAccessError::GameNotFound(meta.id.clone()));
}
let header = generate_authorization_header();
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(
&client,
let response = generate_url(
context,
&["/api/v1/client/game/version"],
&[
("id", &meta.id),
("version", meta.version.as_ref().unwrap()),
],
|f| f.header("Authorization", header),
)?
.send()?;
)?;
let response = client
.get(response)
.header("Authorization", generate_authorization_header(context))
.send()?;
let game_version: GameVersion = response.json()?;
@ -488,6 +452,7 @@ pub fn on_game_complete(
.game_statuses
.insert(meta.id.clone(), status.clone());
drop(db_handle);
app_handle
.emit(
&format!("update_game/{}", meta.id),
@ -508,6 +473,13 @@ pub fn push_game_update(
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_handle
.emit(
&format!("update_game/{game_id}"),
@ -519,48 +491,3 @@ pub fn push_game_update(
)
.unwrap();
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendGameOptions {
launch_string: String,
}
#[tauri::command]
pub fn update_game_configuration(
game_id: String,
options: FrontendGameOptions,
) -> Result<(), LibraryError> {
let mut handle = borrow_db_mut_checked();
let installed_version = handle
.applications
.installed_game_version
.get(&game_id)
.ok_or(LibraryError::MetaNotFound(game_id))?;
let id = installed_version.id.clone();
let version = installed_version.version.clone().unwrap();
let mut existing_configuration = handle
.applications
.game_versions
.get(&id)
.unwrap()
.get(&version)
.unwrap()
.clone();
// Add more options in here
existing_configuration.launch_command_template = options.launch_string;
// Add no more options past here
handle
.applications
.game_versions
.get_mut(&id)
.unwrap()
.insert(version.to_string(), existing_configuration);
Ok(())
}

View File

@ -1,4 +1,4 @@
use crate::database::models::data::{ApplicationTransientStatus, Database, GameDownloadStatus};
// use drop_database::models::data::{ApplicationTransientStatus, Database, DownloadType, DownloadableMetadata, GameDownloadStatus};
pub type GameStatusWithTransient = (
Option<GameDownloadStatus>,
@ -8,10 +8,16 @@ pub struct GameStatusManager {}
impl GameStatusManager {
pub fn fetch_state(game_id: &String, database: &Database) -> GameStatusWithTransient {
let online_state = match database.applications.installed_game_version.get(game_id) {
Some(meta) => database.applications.transient_statuses.get(meta).cloned(),
None => None,
};
let online_state = database
.applications
.transient_statuses
.get(&DownloadableMetadata {
id: game_id.to_string(),
download_type: DownloadType::Game,
version: None,
})
.cloned();
let offline_state = database.applications.game_statuses.get(game_id).cloned();
if online_state.is_some() {

View File

@ -0,0 +1,18 @@
[package]
name = "drop-process"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.42"
drop-database = { path = "../drop-database" }
drop-errors = { path = "../drop-errors" }
drop-native-library = { path = "../drop-native-library" }
dynfmt = { version = "0.1.5", features = ["curly"] }
log = "0.4.28"
page_size = "0.6.0"
shared_child = "1.1.1"
sysinfo = "0.37.0"
tauri = "2.8.5"
tauri-plugin-opener = "2.5.0"

View File

@ -0,0 +1,4 @@
mod format;
mod process_handlers;
pub mod process_manager;
pub mod utils;

View File

@ -1,11 +1,16 @@
use log::debug;
use crate::{
AppState,
database::models::data::{Database, DownloadableMetadata, GameVersion},
process::process_manager::{Platform, ProcessHandler},
use std::{
ffi::OsStr,
path::PathBuf,
process::{Command, Stdio},
sync::LazyLock,
};
use drop_database::{models::data::{Database, DownloadableMetadata, GameVersion}, process::Platform};
use log::{debug, info};
use crate::process_manager::ProcessHandler;
pub struct NativeGameLauncher;
impl ProcessHandler for NativeGameLauncher {
fn create_launch_process(
@ -19,12 +24,36 @@ impl ProcessHandler for NativeGameLauncher {
format!("\"{}\" {}", launch_command, args.join(" "))
}
fn valid_for_platform(&self, _db: &Database, _state: &AppState, _target: &Platform) -> bool {
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
true
}
}
pub const UMU_LAUNCHER_EXECUTABLE: &str = "umu-run";
pub static UMU_LAUNCHER_EXECUTABLE: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
let x = get_umu_executable();
info!("{:?}", &x);
x
});
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()
}
pub struct UMULauncher;
impl ProcessHandler for UMULauncher {
fn create_launch_process(
@ -47,18 +76,15 @@ impl ProcessHandler for UMULauncher {
None => game_version.game_id.clone(),
};
format!(
"GAMEID={game_id} {umu} \"{launch}\" {args}",
umu = UMU_LAUNCHER_EXECUTABLE,
"GAMEID={game_id} {umu:?} \"{launch}\" {args}",
umu = UMU_LAUNCHER_EXECUTABLE.as_ref().unwrap(),
launch = launch_command,
args = args.join(" ")
)
}
fn valid_for_platform(&self, _db: &Database, state: &AppState, _target: &Platform) -> bool {
let Some(ref compat_info) = state.compat_info else {
return false;
};
compat_info.umu_installed
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
UMU_LAUNCHER_EXECUTABLE.is_some()
}
}
@ -80,7 +106,10 @@ impl ProcessHandler for AsahiMuvmLauncher {
game_version,
current_dir,
);
let mut args_cmd = umu_string.split("umu-run").collect::<Vec<&str>>().into_iter();
let mut args_cmd = umu_string
.split("umu-run")
.collect::<Vec<&str>>()
.into_iter();
let args = args_cmd.next().unwrap().trim();
let cmd = format!("umu-run{}", args_cmd.next().unwrap());
@ -89,7 +118,7 @@ impl ProcessHandler for AsahiMuvmLauncher {
#[allow(unreachable_code)]
#[allow(unused_variables)]
fn valid_for_platform(&self, _db: &Database, state: &AppState, _target: &Platform) -> bool {
fn valid_for_platform(&self, _db: &Database, _target: &Platform) -> bool {
#[cfg(not(target_os = "linux"))]
return false;
@ -101,10 +130,6 @@ impl ProcessHandler for AsahiMuvmLauncher {
return false;
}
let Some(ref compat_info) = state.compat_info else {
return false;
};
compat_info.umu_installed
UMU_LAUNCHER_EXECUTABLE.is_some()
}
}

View File

@ -10,30 +10,17 @@ use std::{
time::{Duration, SystemTime},
};
use drop_database::{borrow_db_checked, borrow_db_mut_checked, db::DATA_ROOT_DIR, models::data::{ApplicationTransientStatus, Database, DownloadType, DownloadableMetadata, GameDownloadStatus, GameVersion}, process::Platform, DB};
use drop_errors::process_error::ProcessError;
use drop_native_library::{library::push_game_update, state::GameStatusManager};
use dynfmt::Format;
use dynfmt::SimpleCurlyFormat;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use shared_child::SharedChild;
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Emitter};
use tauri_plugin_opener::OpenerExt;
use crate::{
AppState, DB,
database::{
db::{DATA_ROOT_DIR, borrow_db_checked, borrow_db_mut_checked},
models::data::{
ApplicationTransientStatus, Database, DownloadType, DownloadableMetadata,
GameDownloadStatus, GameVersion,
},
},
error::process_error::ProcessError,
games::{library::push_game_update, state::GameStatusManager},
process::{
format::DropFormatArgs,
process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher},
},
};
use crate::{format::DropFormatArgs, process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher}};
pub struct RunningProcess {
handle: Arc<SharedChild>,
@ -172,16 +159,28 @@ impl ProcessManager<'_> {
let _ = self.app_handle.emit("launch_external_error", &game_id);
}
let status = GameStatusManager::fetch_state(&game_id, &db_handle);
drop(db_handle);
// This is too many unwraps for me to be comfortable
let version_data = db_handle
.applications
.game_versions
.get(&game_id)
.unwrap()
.get(&meta.version.unwrap())
.unwrap();
push_game_update(&self.app_handle, &game_id, None, status);
let status = GameStatusManager::fetch_state(&game_id, &db_handle);
push_game_update(
&self.app_handle,
&game_id,
Some(version_data.clone()),
status,
);
}
fn fetch_process_handler(
&self,
db_lock: &Database,
state: &AppState,
target_platform: &Platform,
) -> Result<&(dyn ProcessHandler + Send + Sync), ProcessError> {
Ok(self
@ -191,22 +190,22 @@ impl ProcessManager<'_> {
let (e_current, e_target) = e.0;
e_current == self.current_platform
&& e_target == *target_platform
&& e.1.valid_for_platform(db_lock, state, target_platform)
&& e.1.valid_for_platform(db_lock, target_platform)
})
.ok_or(ProcessError::InvalidPlatform)?
.1)
}
pub fn valid_platform(&self, platform: &Platform, state: &AppState) -> Result<bool, String> {
pub fn valid_platform(&self, platform: &Platform,) -> Result<bool, String> {
let db_lock = borrow_db_checked();
let process_handler = self.fetch_process_handler(&db_lock, state, platform);
let process_handler = self.fetch_process_handler(&db_lock, platform);
Ok(process_handler.is_ok())
}
pub fn launch_process(
&mut self,
game_id: String,
state: &AppState,
process_manager_lock: &'static Mutex<ProcessManager<'static>>,
) -> Result<(), ProcessError> {
if self.processes.contains_key(&game_id) {
return Err(ProcessError::AlreadyRunning);
@ -291,7 +290,7 @@ impl ProcessManager<'_> {
let target_platform = game_version.platform;
let process_handler = self.fetch_process_handler(&db_lock, state, &target_platform)?;
let process_handler = self.fetch_process_handler(&db_lock, &target_platform)?;
let (launch, args) = match game_status {
GameDownloadStatus::Installed {
@ -334,11 +333,10 @@ impl ProcessManager<'_> {
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
let mut command = Command::new("start");
let mut command = Command::new("cmd");
#[cfg(target_os = "windows")]
command.raw_arg(format!("/min cmd /C \"{}\"", &launch_string));
command.raw_arg(format!("/C \"{}\"", &launch_string));
info!("launching (in {install_dir}): {launch_string}",);
@ -373,22 +371,17 @@ impl ProcessManager<'_> {
);
let wait_thread_handle = launch_process_handle.clone();
let wait_thread_apphandle = self.app_handle.clone();
let wait_thread_game_id = meta.clone();
spawn(move || {
let result: Result<ExitStatus, std::io::Error> = launch_process_handle.wait();
let app_state = wait_thread_apphandle.state::<Mutex<AppState>>();
let app_state_handle = app_state.lock().unwrap();
let mut process_manager_handle = app_state_handle.process_manager.lock().unwrap();
let mut process_manager_handle = process_manager_lock.lock().unwrap();
process_manager_handle.on_process_finish(wait_thread_game_id.id, result);
// As everything goes out of scope, they should get dropped
// But just to explicit about it
drop(process_manager_handle);
drop(app_state_handle);
});
self.processes.insert(
@ -403,51 +396,6 @@ impl ProcessManager<'_> {
}
}
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Copy, Debug)]
pub enum Platform {
Windows,
Linux,
MacOs,
}
impl Platform {
#[cfg(target_os = "windows")]
pub const HOST: Platform = Self::Windows;
#[cfg(target_os = "macos")]
pub const HOST: Platform = Self::MacOs;
#[cfg(target_os = "linux")]
pub const HOST: Platform = Self::Linux;
pub fn is_case_sensitive(&self) -> bool {
match self {
Self::Windows | Self::MacOs => false,
Self::Linux => true,
}
}
}
impl From<&str> for Platform {
fn from(value: &str) -> Self {
match value.to_lowercase().trim() {
"windows" => Self::Windows,
"linux" => Self::Linux,
"mac" | "macos" => Self::MacOs,
_ => unimplemented!(),
}
}
}
impl From<whoami::Platform> for Platform {
fn from(value: whoami::Platform) -> Self {
match value {
whoami::Platform::Windows => Platform::Windows,
whoami::Platform::Linux => Platform::Linux,
whoami::Platform::MacOS => Platform::MacOs,
_ => unimplemented!(),
}
}
}
pub trait ProcessHandler: Send + 'static {
fn create_launch_process(
&self,
@ -458,5 +406,5 @@ pub trait ProcessHandler: Send + 'static {
current_dir: &str,
) -> String;
fn valid_for_platform(&self, db: &Database, state: &AppState, target: &Platform) -> bool;
fn valid_for_platform(&self, db: &Database, target: &Platform) -> bool;
}

View File

@ -1,10 +1,8 @@
use std::path::PathBuf;
use std::{io, path::PathBuf, sync::Arc};
use futures_lite::io;
use drop_errors::application_download_error::ApplicationDownloadError;
use sysinfo::{Disk, DiskRefreshKind, Disks};
use crate::error::application_download_error::ApplicationDownloadError;
pub fn get_disk_available(mount_point: PathBuf) -> Result<u64, ApplicationDownloadError> {
let disks = Disks::new_with_refreshed_list_specifics(DiskRefreshKind::nothing().with_storage());
@ -21,7 +19,7 @@ pub fn get_disk_available(mount_point: PathBuf) -> Result<u64, ApplicationDownlo
return Ok(disk.available_space());
}
}
Err(ApplicationDownloadError::IoError(io::Error::other(
Err(ApplicationDownloadError::IoError(Arc::new(io::Error::other(
"could not find disk of path",
).kind()))
))))
}

View File

@ -0,0 +1,20 @@
[package]
name = "drop-remote"
version = "0.1.0"
edition = "2024"
[dependencies]
bitcode = "0.6.7"
chrono = "0.4.42"
drop-consts = { path = "../drop-consts" }
drop-errors = { path = "../drop-errors" }
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"
serde = { version = "1.0.220", features = ["derive"] }
tauri = "2.8.5"
url = "2.5.7"

View File

@ -0,0 +1,156 @@
use std::{collections::HashMap, env, sync::Mutex};
use chrono::Utc;
use drop_errors::{drop_server_error::ServerError, remote_access_error::RemoteAccessError};
use droplet_rs::ssl::sign_nonce;
use gethostname::gethostname;
use log::{debug, error, warn};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
requests::make_authenticated_get, utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC}, DropRemoteAuth, DropRemoteContext
};
use super::requests::generate_url;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CapabilityConfiguration {}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InitiateRequestBody {
name: String,
platform: String,
capabilities: HashMap<String, CapabilityConfiguration>,
mode: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HandshakeRequestBody {
client_id: String,
token: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct HandshakeResponse {
private: String,
certificate: String,
id: String,
}
pub fn generate_authorization_header(context: &DropRemoteContext) -> String {
let auth = if let Some(auth) = &context.auth {
auth
} else {
return "".to_owned();
};
let nonce = Utc::now().timestamp_millis().to_string();
let signature = sign_nonce(auth.private.clone(), nonce.clone()).unwrap();
format!("Nonce {} {} {}", auth.client_id, nonce, signature)
}
pub async fn fetch_user(context: &DropRemoteContext) -> Result<Vec<u8>, RemoteAccessError> {
let response =
make_authenticated_get(context, generate_url(context, &["/api/v1/client/user"], &[])?).await?;
if response.status() != 200 {
let err: ServerError = response.json().await?;
warn!("{err:?}");
if err.status_message == "Nonce expired" {
return Err(RemoteAccessError::OutOfSync);
}
return Err(RemoteAccessError::InvalidResponse(err));
}
response
.bytes()
.await
.map_err(std::convert::Into::into)
.map(|v| v.to_vec())
}
pub async fn recieve_handshake_logic(
context: &mut DropRemoteContext,
path: String,
) -> Result<(), RemoteAccessError> {
let path_chunks: Vec<&str> = path.split('/').collect();
if path_chunks.len() != 3 {
// app.emit("auth/failed", ()).unwrap();
return Err(RemoteAccessError::HandshakeFailed(
"failed to parse token".to_string(),
));
}
let client_id = path_chunks.get(1).unwrap();
let token = path_chunks.get(2).unwrap();
let body = HandshakeRequestBody {
client_id: (*client_id).to_string(),
token: (*token).to_string(),
};
let endpoint = generate_url(context, &["/api/v1/client/auth/handshake"], &[])?;
let client = DROP_CLIENT_ASYNC.clone();
let response = client.post(endpoint).json(&body).send().await?;
debug!("handshake responsded with {}", response.status().as_u16());
if !response.status().is_success() {
return Err(RemoteAccessError::InvalidResponse(response.json().await?));
}
let response_struct: HandshakeResponse = response.json().await?;
let web_token = {
let header = generate_authorization_header(context);
let token = client
.post(generate_url(context, &["/api/v1/client/user/webtoken"], &[])?)
.header("Authorization", header)
.send()
.await
.unwrap();
token.text().await.unwrap()
};
context.auth = Some(DropRemoteAuth {
private: response_struct.private,
cert: response_struct.certificate,
client_id: response_struct.id,
web_token: web_token,
});
Ok(())
}
pub fn auth_initiate_logic(context: &DropRemoteContext, mode: String) -> Result<String, RemoteAccessError> {
let hostname = gethostname();
let endpoint = generate_url(context, &["/api/v1/client/auth/initiate"], &[])?;
let body = InitiateRequestBody {
name: format!("{} (Desktop)", hostname.into_string().unwrap()),
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: ServerError = response.json()?;
error!("could not start handshake: {}", data.status_message);
return Err(RemoteAccessError::HandshakeFailed(data.status_message));
}
let response = response.text()?;
Ok(response)
}

View File

@ -5,22 +5,22 @@ use std::{
time::SystemTime,
};
use crate::{
database::{db::borrow_db_checked, models::data::Database},
error::remote_access_error::RemoteAccessError,
};
use bitcode::{Decode, DecodeOwned, Encode};
use drop_consts::CACHE_DIR;
use drop_errors::remote_access_error::RemoteAccessError;
use http::{Response, header::CONTENT_TYPE, response::Builder as ResponseBuilder};
use log::debug;
#[macro_export]
macro_rules! offline {
($var:expr, $func1:expr, $func2:expr, $( $arg:expr ),* ) => {
if $crate::borrow_db_checked().settings.force_offline || $var.lock().unwrap().status == $crate::AppStatus::Offline {
$func2( $( $arg ), *)
// TODO add offline mode back
// || $var.lock().unwrap().status == AppStatus::Offline
async move { if drop_database::borrow_db_checked().settings.force_offline {
$func2( $( $arg ), *).await
} else {
$func1( $( $arg ), *)
$func1( $( $arg ), *).await
}
}
}
}
@ -50,38 +50,43 @@ fn read_sync(base: &Path, key: &str) -> io::Result<Vec<u8>> {
Ok(file)
}
fn delete_sync(base: &Path, key: &str) -> io::Result<()> {
let cache_path = get_cache_path(base, key);
std::fs::remove_file(cache_path)?;
Ok(())
}
pub fn cache_object<D: Encode>(key: &str, data: &D) -> Result<(), RemoteAccessError> {
cache_object_db(key, data, &borrow_db_checked())
cache_object_db(key, data)
}
pub fn cache_object_db<D: Encode>(
key: &str,
data: &D,
database: &Database,
) -> Result<(), RemoteAccessError> {
let bytes = bitcode::encode(data);
write_sync(&database.cache_dir, key, bytes).map_err(RemoteAccessError::Cache)
write_sync(&CACHE_DIR, key, bytes).map_err(RemoteAccessError::Cache)
}
pub fn get_cached_object<D: Encode + DecodeOwned>(key: &str) -> Result<D, RemoteAccessError> {
get_cached_object_db::<D>(key, &borrow_db_checked())
get_cached_object_db::<D>(key)
}
pub fn get_cached_object_db<D: DecodeOwned>(
key: &str,
db: &Database,
) -> Result<D, RemoteAccessError> {
let start = SystemTime::now();
let bytes = read_sync(&db.cache_dir, key).map_err(RemoteAccessError::Cache)?;
let read = start.elapsed().unwrap();
let bytes = read_sync(&CACHE_DIR, key).map_err(RemoteAccessError::Cache)?;
let data =
bitcode::decode::<D>(&bytes).map_err(|e| RemoteAccessError::Cache(io::Error::other(e)))?;
let decode = start.elapsed().unwrap();
debug!(
"cache object took: r:{}, d:{}, b:{}",
read.as_millis(),
read.abs_diff(decode).as_millis(),
bytes.len()
);
Ok(data)
}
pub fn clear_cached_object(key: &str) -> Result<(), RemoteAccessError> {
clear_cached_object_db(key)
}
pub fn clear_cached_object_db(
key: &str,
) -> Result<(), RemoteAccessError> {
delete_sync(&CACHE_DIR, key).map_err(RemoteAccessError::Cache)?;
Ok(())
}
#[derive(Encode, Decode)]
pub struct ObjectCache {
content_type: String,

View File

@ -1,15 +1,16 @@
use http::{header::CONTENT_TYPE, response::Builder as ResponseBuilder};
use http::{header::CONTENT_TYPE, response::Builder as ResponseBuilder, Request};
use log::warn;
use tauri::UriSchemeResponder;
use crate::{database::db::DatabaseImpls, remote::utils::DROP_CLIENT_ASYNC, DB};
use crate::{requests::generate_url, utils::DROP_CLIENT_ASYNC, DropRemoteContext};
use super::{
auth::generate_authorization_header,
cache::{ObjectCache, cache_object, get_cached_object},
};
pub async fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeResponder) {
pub async fn fetch_object(context: &DropRemoteContext, request: Request<Vec<u8>>, responder: UriSchemeResponder) {
// Drop leading /
let object_id = &request.uri().path()[1..];
@ -21,9 +22,9 @@ pub async fn fetch_object(request: http::Request<Vec<u8>>, responder: UriSchemeR
return;
}
let header = generate_authorization_header();
let header = generate_authorization_header(context);
let client = DROP_CLIENT_ASYNC.clone();
let url = format!("{}api/v1/client/object/{object_id}", DB.fetch_base_url());
let url = generate_url(context, &["/api/v1/client/object", object_id], &[]).expect("failed to generated object url");
let response = client.get(url).header("Authorization", header).send().await;
if response.is_err() {

View File

@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use url::Url;
pub mod auth;
pub mod cache;
pub mod fetch_object;
pub mod requests;
pub mod utils;
#[derive(Serialize, Deserialize, Clone)]
struct DropRemoteAuth {
private: String,
cert: String,
client_id: String,
web_token: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DropRemoteContext {
base_url: Url,
auth: Option<DropRemoteAuth>,
}
impl DropRemoteContext {
pub fn new(base_url: Url) -> Self {
DropRemoteContext { base_url, auth: None }
}
}

View File

@ -0,0 +1,30 @@
use drop_errors::remote_access_error::RemoteAccessError;
use url::Url;
use crate::{auth::generate_authorization_header, utils::DROP_CLIENT_ASYNC, DropRemoteContext};
pub fn generate_url<T: AsRef<str>>(
context: &DropRemoteContext,
path_components: &[T],
query: &[(T, T)],
) -> Result<Url, RemoteAccessError> {
let mut base_url = context.base_url.clone();
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(context: &DropRemoteContext, url: Url) -> Result<reqwest::Response, reqwest::Error> {
DROP_CLIENT_ASYNC
.get(url)
.header("Authorization", generate_authorization_header(context))
.send()
.await
}

View File

@ -1,26 +1,12 @@
use std::{
fs::{self, File},
io::Read,
sync::{LazyLock, Mutex},
time::Duration,
sync::LazyLock,
};
use log::{debug, info, warn};
use drop_consts::DATA_ROOT_DIR;
use log::{debug, info};
use reqwest::Certificate;
use serde::Deserialize;
use url::Url;
use crate::{
AppState, AppStatus,
database::db::{DATA_ROOT_DIR, borrow_db_mut_checked},
error::remote_access_error::RemoteAccessError,
};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DropHealthcheck {
app_name: String,
}
static DROP_CERT_BUNDLE: LazyLock<Vec<Certificate>> = LazyLock::new(fetch_certificates);
pub static DROP_CLIENT_SYNC: LazyLock<reqwest::blocking::Client> = LazyLock::new(get_client_sync);
@ -82,37 +68,4 @@ pub fn get_client_ws() -> reqwest::Client {
client = client.add_root_certificate(cert.clone());
}
client.use_rustls_tls().http1_only().build().unwrap()
}
pub async fn use_remote_logic(
url: String,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<(), RemoteAccessError> {
debug!("connecting to url {url}");
let base_url = Url::parse(&url)?;
// Test Drop url
let test_endpoint = base_url.join("/api/v1")?;
let client = DROP_CLIENT_ASYNC.clone();
let response = client
.get(test_endpoint.to_string())
.timeout(Duration::from_secs(3))
.send()
.await?;
let result: DropHealthcheck = response.json().await?;
if result.app_name != "Drop" {
warn!("user entered drop endpoint that connected, but wasn't identified as Drop");
return Err(RemoteAccessError::InvalidEndpoint);
}
let mut app_state = state.lock().unwrap();
app_state.status = AppStatus::SignedOut;
drop(app_state);
let mut db_state = borrow_db_mut_checked();
db_state.base_url = base_url.to_string();
Ok(())
}
}

59
src-tauri/src/auth.rs Normal file
View File

@ -0,0 +1,59 @@
use std::sync::Mutex;
use drop_database::{borrow_db_checked, runtime_models::User};
use drop_errors::remote_access_error::RemoteAccessError;
use drop_remote::{auth::{fetch_user, recieve_handshake_logic}, cache::{cache_object, clear_cached_object, get_cached_object}};
use log::warn;
use tauri::{AppHandle, Emitter as _, Manager as _};
use crate::{AppState, AppStatus};
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").unwrap();
return (AppStatus::Offline, Some(user));
}
Err(_) => return (AppStatus::SignedInNeedsReauth, None),
};
cache_object("user", &user_result).unwrap();
return (AppStatus::SignedIn, Some(user_result));
}
(AppStatus::SignedOut, None)
}
pub async fn recieve_handshake(app: AppHandle, path: String) {
// Tell the app we're processing
app.emit("auth/processing", ()).unwrap();
let handshake_result = recieve_handshake_logic(path).await;
if let Err(e) = handshake_result {
warn!("error with authentication: {e}");
app.emit("auth/failed", e.to_string()).unwrap();
return;
}
let app_state = app.state::<Mutex<AppState>>();
let (app_status, user) = setup().await;
let mut state_lock = app_state.lock().unwrap();
state_lock.status = app_status;
state_lock.user = user;
let _ = clear_cached_object("collections");
let _ = clear_cached_object("library");
drop(state_lock);
app.emit("auth/finished", ()).unwrap();
}

View File

@ -1,4 +1,4 @@
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
use drop_database::{borrow_db_checked, borrow_db_mut_checked};
use log::debug;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;

View File

@ -4,11 +4,11 @@ use tauri::AppHandle;
use crate::AppState;
#[tauri::command]
pub fn quit(app: tauri::AppHandle, state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>) {
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<'_>>>) {
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() {

View File

@ -2,7 +2,7 @@ use crate::AppState;
#[tauri::command]
pub fn fetch_state(
state: tauri::State<'_, std::sync::Mutex<AppState<'_>>>,
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())?;

View File

@ -4,17 +4,12 @@ use std::{
path::{Path, PathBuf},
};
use drop_database::{borrow_db_checked, borrow_db_mut_checked, db::DATA_ROOT_DIR, debug::SystemData, models::data::Settings};
use drop_errors::download_manager_error::DownloadManagerError;
use serde_json::Value;
use crate::{
database::{db::borrow_db_mut_checked, scan::scan_install_dirs}, error::download_manager_error::DownloadManagerError,
};
use crate::database::scan::scan_install_dirs;
use super::{
db::{borrow_db_checked, DATA_ROOT_DIR},
debug::SystemData,
models::data::Settings,
};
// Will, in future, return disk/remaining size
// Just returns the directories that have been set up

View File

@ -1,5 +1,2 @@
pub mod commands;
pub mod db;
pub mod debug;
pub mod models;
pub mod scan;

View File

@ -1,18 +1,9 @@
use std::fs;
use drop_database::{borrow_db_mut_checked, drop_data::{DropData, DROP_DATA_PATH}, models::data::{DownloadType, DownloadableMetadata}};
use drop_native_library::library::set_partially_installed_db;
use log::warn;
use crate::{
database::{
db::borrow_db_mut_checked,
models::data::v1::{DownloadType, DownloadableMetadata},
},
games::{
downloads::drop_data::{v1::DropData, DROP_DATA_PATH},
library::set_partially_installed_db,
},
};
pub fn scan_install_dirs() {
let mut db_lock = borrow_db_mut_checked();
for install_dir in db_lock.applications.install_dirs.clone() {

View File

@ -1,6 +1,8 @@
use std::sync::Mutex;
use crate::{database::models::data::DownloadableMetadata, AppState};
use drop_database::models::data::DownloadableMetadata;
use crate::AppState;
#[tauri::command]
pub fn pause_downloads(state: tauri::State<'_, Mutex<AppState>>) {

View File

@ -1,5 +1 @@
pub mod commands;
pub mod download_manager_builder;
pub mod download_manager_frontend;
pub mod downloadable;
pub mod util;

View File

@ -1,109 +0,0 @@
use serde_json::json;
use url::Url;
use crate::{
DB,
database::db::DatabaseImpls,
error::remote_access_error::RemoteAccessError,
remote::{auth::generate_authorization_header, requests::make_request, utils::DROP_CLIENT_SYNC},
};
use super::collection::{Collection, Collections};
#[tauri::command]
pub fn fetch_collections() -> Result<Collections, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(&client, &["/api/v1/client/collection"], &[], |r| {
r.header("Authorization", generate_authorization_header())
})?
.send()?;
Ok(response.json()?)
}
#[tauri::command]
pub fn fetch_collection(collection_id: String) -> Result<Collection, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(
&client,
&["/api/v1/client/collection/", &collection_id],
&[],
|r| r.header("Authorization", generate_authorization_header()),
)?
.send()?;
Ok(response.json()?)
}
#[tauri::command]
pub fn create_collection(name: String) -> Result<Collection, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let base_url = DB.fetch_base_url();
let base_url = Url::parse(&format!("{base_url}api/v1/client/collection/"))?;
let response = client
.post(base_url)
.header("Authorization", generate_authorization_header())
.json(&json!({"name": name}))
.send()?;
Ok(response.json()?)
}
#[tauri::command]
pub fn add_game_to_collection(
collection_id: String,
game_id: String,
) -> Result<(), RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let url = Url::parse(&format!(
"{}api/v1/client/collection/{}/entry/",
DB.fetch_base_url(),
collection_id
))?;
client
.post(url)
.header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id}))
.send()?;
Ok(())
}
#[tauri::command]
pub fn delete_collection(collection_id: String) -> Result<bool, RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let base_url = Url::parse(&format!(
"{}api/v1/client/collection/{}",
DB.fetch_base_url(),
collection_id
))?;
let response = client
.delete(base_url)
.header("Authorization", generate_authorization_header())
.send()?;
Ok(response.json()?)
}
#[tauri::command]
pub fn delete_game_in_collection(
collection_id: String,
game_id: String,
) -> Result<(), RemoteAccessError> {
let client = DROP_CLIENT_SYNC.clone();
let base_url = Url::parse(&format!(
"{}api/v1/client/collection/{}/entry",
DB.fetch_base_url(),
collection_id
))?;
client
.delete(base_url)
.header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id}))
.send()?;
Ok(())
}

View File

@ -1,2 +0,0 @@
pub mod collection;
pub mod commands;

View File

@ -1,76 +0,0 @@
use std::sync::Mutex;
use tauri::AppHandle;
use crate::{
AppState,
database::{
db::borrow_db_checked,
models::data::GameVersion,
},
error::{library_error::LibraryError, remote_access_error::RemoteAccessError},
games::library::{
fetch_game_logic_offline, fetch_library_logic_offline, get_current_meta,
uninstall_game_logic,
},
offline,
};
use super::{
library::{
FetchGameStruct, Game, fetch_game_logic, fetch_game_verion_options_logic,
fetch_library_logic,
},
state::{GameStatusManager, GameStatusWithTransient},
};
#[tauri::command]
pub fn fetch_library(
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<Vec<Game>, RemoteAccessError> {
offline!(
state,
fetch_library_logic,
fetch_library_logic_offline,
state
)
}
#[tauri::command]
pub fn fetch_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
offline!(
state,
fetch_game_logic,
fetch_game_logic_offline,
game_id,
state
)
}
#[tauri::command]
pub fn fetch_game_status(id: String) -> GameStatusWithTransient {
let db_handle = borrow_db_checked();
GameStatusManager::fetch_state(&id, &db_handle)
}
#[tauri::command]
pub fn uninstall_game(game_id: String, app_handle: AppHandle) -> Result<(), LibraryError> {
let meta = match get_current_meta(&game_id) {
Some(data) => data,
None => return Err(LibraryError::MetaNotFound(game_id)),
};
uninstall_game_logic(meta, &app_handle);
Ok(())
}
#[tauri::command]
pub fn fetch_game_verion_options(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<Vec<GameVersion>, RemoteAccessError> {
fetch_game_verion_options_logic(game_id, state)
}

View File

@ -1,78 +0,0 @@
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use crate::{
database::{db::borrow_db_checked, models::data::GameDownloadStatus},
download_manager::
downloadable::Downloadable
,
error::application_download_error::ApplicationDownloadError,
AppState,
};
use super::download_agent::GameDownloadAgent;
#[tauri::command]
pub fn download_game(
game_id: String,
game_version: String,
install_dir: usize,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), ApplicationDownloadError> {
let sender = state.lock().unwrap().download_manager.get_sender();
let game_download_agent = GameDownloadAgent::new_from_index(
game_id,
game_version,
install_dir,
sender,
)?;
let game_download_agent = Arc::new(Box::new(game_download_agent) as Box<dyn Downloadable + Send + Sync>);
state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent).unwrap();
Ok(())
}
#[tauri::command]
pub fn resume_download(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), ApplicationDownloadError> {
let s = borrow_db_checked()
.applications
.game_statuses
.get(&game_id)
.unwrap()
.clone();
let (version_name, install_dir) = match s {
GameDownloadStatus::Remote {} => unreachable!(),
GameDownloadStatus::SetupRequired { .. } => unreachable!(),
GameDownloadStatus::Installed { .. } => unreachable!(),
GameDownloadStatus::PartiallyInstalled {
version_name,
install_dir,
} => (version_name, install_dir),
};
let sender = state.lock().unwrap().download_manager.get_sender();
let parent_dir: PathBuf = install_dir.into();
let game_download_agent = Arc::new(Box::new(GameDownloadAgent::new(
game_id,
version_name.clone(),
parent_dir.parent().unwrap().to_path_buf(),
sender,
)?) as Box<dyn Downloadable + Send + Sync>);
state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent).unwrap();
Ok(())
}

View File

@ -1,214 +0,0 @@
use crate::download_manager::util::download_thread_control_flag::{
DownloadThreadControl, DownloadThreadControlFlag,
};
use crate::download_manager::util::progress_object::ProgressHandle;
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::drop_server_error::DropServerError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::manifest::DropDownloadContext;
use crate::remote::auth::generate_authorization_header;
use log::{debug, warn};
use md5::{Context, Digest};
use reqwest::blocking::{RequestBuilder, Response};
use std::fs::{set_permissions, Permissions};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write},
path::PathBuf,
};
pub struct DropWriter<W: Write> {
hasher: Context,
destination: W,
}
impl DropWriter<File> {
fn new(path: PathBuf) -> Self {
let destination = OpenOptions::new().write(true).create(true).truncate(false).open(&path).unwrap();
Self {
destination,
hasher: Context::new(),
}
}
fn finish(mut self) -> io::Result<Digest> {
self.flush().unwrap();
Ok(self.hasher.compute())
}
}
// 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}")))?;
self.destination.write(buf)
}
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 destination: DropWriter<W>,
pub control_flag: &'a DownloadThreadControl,
pub progress: ProgressHandle,
pub size: usize,
}
impl<'a> DropDownloadPipeline<'a, Response, File> {
fn new(
source: Response,
destination: DropWriter<File>,
control_flag: &'a DownloadThreadControl,
progress: ProgressHandle,
size: usize,
) -> Self {
Self {
source,
destination,
control_flag,
progress,
size,
}
}
fn copy(&mut self) -> Result<bool, io::Error> {
let copy_buf_size = 512;
let mut copy_buf = vec![0; copy_buf_size];
let mut buf_writer = BufWriter::with_capacity(1024 * 1024, &mut self.destination);
let mut current_size = 0;
loop {
if self.control_flag.get() == DownloadThreadControlFlag::Stop {
buf_writer.flush()?;
return Ok(false);
}
let mut bytes_read = self.source.read(&mut copy_buf)?;
current_size += bytes_read;
if current_size > self.size {
let over = current_size - self.size;
warn!("server sent too many bytes... {over} over");
bytes_read -= over;
current_size = self.size;
}
buf_writer.write_all(&copy_buf[0..bytes_read])?;
self.progress.add(bytes_read);
if current_size >= self.size {
debug!(
"finished with final size of {} vs {}",
current_size, self.size
);
break;
}
}
buf_writer.flush()?;
Ok(true)
}
fn finish(self) -> Result<Digest, io::Error> {
let checksum = self.destination.finish()?;
Ok(checksum)
}
}
pub fn download_game_chunk(
ctx: &DropDownloadContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
request: RequestBuilder,
) -> Result<bool, ApplicationDownloadError> {
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let response = request
.header("Authorization", generate_authorization_header())
.send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
debug!("chunk request got status code: {}", response.status());
let raw_res = response.text().unwrap();
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 mut destination = DropWriter::new(ctx.path.clone());
if ctx.offset != 0 {
destination
.seek(SeekFrom::Start(ctx.offset))
.expect("Failed to seek to file offset");
}
let content_length = response.content_length();
if content_length.is_none() {
warn!("recieved 0 length content from server");
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(response.json().unwrap()),
));
}
let length = content_length.unwrap().try_into().unwrap();
if length != ctx.length {
return Err(ApplicationDownloadError::DownloadError);
}
let mut pipeline =
DropDownloadPipeline::new(response, destination, control_flag, progress, length);
let completed = pipeline
.copy()
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
if !completed {
return Ok(false);
}
// If we complete the file, set the permissions (if on Linux)
#[cfg(unix)]
{
let permissions = Permissions::from_mode(ctx.permissions);
set_permissions(ctx.path.clone(), permissions).unwrap();
}
let checksum = pipeline
.finish()
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
let res = hex::encode(checksum.0);
if res != ctx.checksum {
return Err(ApplicationDownloadError::Checksum);
}
debug!(
"Successfully finished download #{}, copied {} bytes",
ctx.checksum, length
);
Ok(true)
}

View File

@ -1,27 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
pub type DropManifest = HashMap<String, DropChunk>;
#[derive(Serialize, Deserialize, Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DropChunk {
pub permissions: u32,
pub ids: Vec<String>,
pub checksums: Vec<String>,
pub lengths: Vec<usize>,
pub version_name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DropDownloadContext {
pub file_name: String,
pub version: String,
pub index: usize,
pub offset: u64,
pub game_id: String,
pub path: PathBuf,
pub checksum: String,
pub length: usize,
pub permissions: u32,
}

View File

@ -1,5 +0,0 @@
pub mod collections;
pub mod commands;
pub mod downloads;
pub mod library;
pub mod state;

View File

@ -1,79 +1,63 @@
#![deny(unused_must_use)]
#![feature(fn_traits)]
#![feature(duration_constructors)]
#![feature(duration_millis_float)]
#![feature(iterator_try_collect)]
#![deny(clippy::all)]
mod database;
mod games;
mod auth;
mod client;
mod database;
mod download_manager;
mod error;
mod native_library;
mod process;
mod remote;
mod setup;
use crate::database::scan::scan_install_dirs;
use crate::process::commands::open_process_logs;
use crate::process::process_handlers::UMU_LAUNCHER_EXECUTABLE;
use crate::auth::recieve_handshake;
use crate::native_library::collection_commands::{add_game_to_collection, create_collection, delete_collection, delete_game_in_collection, fetch_collection, fetch_collections};
use crate::native_library::commands::{
fetch_game, fetch_game_status, fetch_game_version_options, fetch_library, uninstall_game,
};
use crate::native_library::downloads::commands::{download_game, resume_download};
use crate::process::commands::{open_process_logs, update_game_configuration};
use crate::remote::commands::auth_initiate_code;
use crate::{database::db::DatabaseImpls, games::downloads::commands::resume_download};
use bitcode::{Decode, Encode};
use crate::remote::server_proto::{handle_server_proto, handle_server_proto_offline};
use client::commands::fetch_state;
use client::{
autostart::{get_autostart_enabled, sync_autostart_on_startup, toggle_autostart},
autostart::{get_autostart_enabled, toggle_autostart},
cleanup::{cleanup_and_exit, quit},
};
use database::commands::{
add_download_dir, delete_download_dir, fetch_download_dir_stats, fetch_settings,
fetch_system_data, update_settings,
};
use database::db::{DATA_ROOT_DIR, DatabaseInterface, borrow_db_checked, borrow_db_mut_checked};
use database::models::data::GameDownloadStatus;
use download_manager::commands::{
cancel_game, move_download_in_queue, pause_downloads, resume_downloads,
};
use download_manager::download_manager_builder::DownloadManagerBuilder;
use download_manager::download_manager_frontend::DownloadManager;
use games::collections::commands::{
add_game_to_collection, create_collection, delete_collection, delete_game_in_collection,
fetch_collection, fetch_collections,
};
use games::commands::{
fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game,
};
use games::downloads::commands::download_game;
use games::library::{Game, update_game_configuration};
use log::{LevelFilter, debug, info, warn};
use log4rs::Config;
use log4rs::append::console::ConsoleAppender;
use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Root};
use log4rs::encode::pattern::PatternEncoder;
use drop_database::borrow_db_mut_checked;
use drop_database::db::DATA_ROOT_DIR;
use drop_database::runtime_models::User;
use drop_downloads::download_manager_frontend::DownloadManager;
use drop_process::process_manager::ProcessManager;
use drop_remote::{fetch_object::fetch_object, offline};
use log::{debug, info, warn};
use process::commands::{kill_game, launch_game};
use process::process_manager::ProcessManager;
use remote::auth::{self, recieve_handshake};
use remote::commands::{
auth_initiate, fetch_drop_object, gen_drop_url, manual_recieve_handshake, retry_connect,
sign_out, use_remote,
};
use remote::fetch_object::fetch_object;
use remote::server_proto::{handle_server_proto, handle_server_proto_offline};
use serde::{Deserialize, Serialize};
use serde::Serialize;
use std::fs::File;
use std::io::Write;
use std::panic::PanicHookInfo;
use std::path::Path;
use std::process::{Command, Stdio};
use std::str::FromStr;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::SystemTime;
use std::{
collections::HashMap,
sync::{LazyLock, Mutex},
};
use std::{env, panic};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager, RunEvent, WindowEvent};
use tauri::{Manager, RunEvent, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::DialogExt;
@ -88,171 +72,18 @@ pub enum AppStatus {
ServerUnavailable,
}
#[derive(Clone, Serialize, Deserialize, Encode, Decode)]
#[serde(rename_all = "camelCase")]
pub struct User {
id: String,
username: String,
admin: bool,
display_name: String,
profile_picture_object_id: String,
}
#[derive(Clone)]
pub struct CompatInfo {
umu_installed: bool,
}
fn create_new_compat_info() -> Option<CompatInfo> {
#[cfg(target_os = "windows")]
return None;
let has_umu_installed = Command::new(UMU_LAUNCHER_EXECUTABLE)
.stdout(Stdio::null())
.spawn();
if let Err(umu_error) = &has_umu_installed {
warn!("disabling windows support with error: {umu_error}");
}
let has_umu_installed = has_umu_installed.is_ok();
Some(CompatInfo {
umu_installed: has_umu_installed,
})
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppState<'a> {
pub struct AppState {
status: AppStatus,
user: Option<User>,
games: HashMap<String, Game>,
#[serde(skip_serializing)]
download_manager: Arc<DownloadManager>,
#[serde(skip_serializing)]
process_manager: Arc<Mutex<ProcessManager<'a>>>,
#[serde(skip_serializing)]
compat_info: Option<CompatInfo>,
process_manager: &'static Mutex<ProcessManager<'static>>,
}
fn setup(handle: AppHandle) -> AppState<'static> {
let logfile = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new(
"{d} | {l} | {f}:{L} - {m}{n}",
)))
.append(false)
.build(DATA_ROOT_DIR.join("./drop.log"))
.unwrap();
let console = ConsoleAppender::builder()
.encoder(Box::new(PatternEncoder::new(
"{d} | {l} | {f}:{L} - {m}{n}",
)))
.build();
let log_level = env::var("RUST_LOG").unwrap_or(String::from("Info"));
let config = Config::builder()
.appenders(vec![
Appender::builder().build("logfile", Box::new(logfile)),
Appender::builder().build("console", Box::new(console)),
])
.build(
Root::builder()
.appenders(vec!["logfile", "console"])
.build(LevelFilter::from_str(&log_level).expect("Invalid log level")),
)
.unwrap();
log4rs::init_config(config).unwrap();
let games = HashMap::new();
let download_manager = Arc::new(DownloadManagerBuilder::build(handle.clone()));
let process_manager = Arc::new(Mutex::new(ProcessManager::new(handle.clone())));
let compat_info = create_new_compat_info();
debug!("checking if database is set up");
let is_set_up = DB.database_is_set_up();
scan_install_dirs();
if !is_set_up {
return AppState {
status: AppStatus::NotConfigured,
user: None,
games,
download_manager,
process_manager,
compat_info,
};
}
debug!("database is set up");
// TODO: Account for possible failure
let (app_status, user) = auth::setup();
let db_handle = borrow_db_checked();
let mut missing_games = Vec::new();
let statuses = db_handle.applications.game_statuses.clone();
drop(db_handle);
for (game_id, status) in statuses {
match status {
GameDownloadStatus::Remote {} => {}
GameDownloadStatus::PartiallyInstalled { .. } => {}
GameDownloadStatus::SetupRequired {
version_name: _,
install_dir,
} => {
let install_dir_path = Path::new(&install_dir);
if !install_dir_path.exists() {
missing_games.push(game_id);
}
}
GameDownloadStatus::Installed {
version_name: _,
install_dir,
} => {
let install_dir_path = Path::new(&install_dir);
if !install_dir_path.exists() {
missing_games.push(game_id);
}
}
}
}
info!("detected games missing: {missing_games:?}");
let mut db_handle = borrow_db_mut_checked();
for game_id in missing_games {
db_handle
.applications
.game_statuses
.entry(game_id)
.and_modify(|v| *v = GameDownloadStatus::Remote {});
}
drop(db_handle);
debug!("finished setup!");
// Sync autostart state
if let Err(e) = sync_autostart_on_startup(&handle) {
warn!("failed to sync autostart state: {e}");
}
AppState {
status: app_status,
user,
games,
download_manager,
process_manager,
compat_info,
}
}
pub static DB: LazyLock<DatabaseInterface> = LazyLock::new(DatabaseInterface::set_up_database);
pub fn custom_panic_handler(e: &PanicHookInfo) -> Option<()> {
let crash_file = DATA_ROOT_DIR.join(format!(
"crash-{}.log",
@ -316,7 +147,7 @@ pub fn run() {
delete_download_dir,
fetch_download_dir_stats,
fetch_game_status,
fetch_game_verion_options,
fetch_game_version_options,
update_game_configuration,
// Collections
fetch_collections,
@ -348,92 +179,99 @@ pub fn run() {
))
.setup(|app| {
let handle = app.handle().clone();
let state = setup(handle);
debug!("initialized drop client");
app.manage(Mutex::new(state));
{
use tauri_plugin_deep_link::DeepLinkExt;
let _ = app.deep_link().register_all();
debug!("registered all pre-defined deep links");
}
tauri::async_runtime::block_on(async move {
let state = setup::setup(handle).await;
info!("initialized drop client");
app.manage(Mutex::new(state));
let handle = app.handle().clone();
{
use tauri_plugin_deep_link::DeepLinkExt;
let _ = app.deep_link().register_all();
debug!("registered all pre-defined deep links");
}
let _main_window = tauri::WebviewWindowBuilder::new(
&handle,
"main", // BTW this is not the name of the window, just the label. Keep this 'main', there are permissions & configs that depend on it
tauri::WebviewUrl::App("main".into()),
)
.title("Drop Desktop App")
.min_inner_size(1000.0, 500.0)
.inner_size(1536.0, 864.0)
.decorations(false)
.shadow(false)
.data_directory(DATA_ROOT_DIR.join(".webview"))
.build()
.unwrap();
let handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
debug!("handling drop:// url");
let binding = event.urls();
let url = binding.first().unwrap();
if url.host_str().unwrap() == "handshake" {
recieve_handshake(handle.clone(), url.path().to_string());
let _main_window = tauri::WebviewWindowBuilder::new(
&handle,
"main", // BTW this is not the name of the window, just the label. Keep this 'main', there are permissions & configs that depend on it
tauri::WebviewUrl::App("main".into()),
)
.title("Drop Desktop App")
.min_inner_size(1000.0, 500.0)
.inner_size(1536.0, 864.0)
.decorations(false)
.shadow(false)
.data_directory(DATA_ROOT_DIR.join(".webview"))
.build()
.unwrap();
app.deep_link().on_open_url(move |event| {
debug!("handling drop:// url");
let binding = event.urls();
let url = binding.first().unwrap();
if url.host_str().unwrap() == "handshake" {
tauri::async_runtime::spawn(recieve_handshake(
handle.clone(),
url.path().to_string(),
));
}
});
let menu = Menu::with_items(
app,
&[
&MenuItem::with_id(app, "open", "Open", true, None::<&str>).unwrap(),
&PredefinedMenuItem::separator(app).unwrap(),
/*
&MenuItem::with_id(app, "show_library", "Library", true, None::<&str>)?,
&MenuItem::with_id(app, "show_settings", "Settings", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
*/
&MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap(),
],
)
.unwrap();
run_on_tray(|| {
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
app.webview_windows().get("main").unwrap().show().unwrap();
}
"quit" => {
cleanup_and_exit(app, &app.state());
}
_ => {
warn!("menu event not handled: {:?}", event.id);
}
})
.build(app)
.expect("error while setting up tray menu");
});
{
let mut db_handle = borrow_db_mut_checked();
if let Some(original) = db_handle.prev_database.take() {
warn!(
"Database corrupted. Original file at {}",
original.canonicalize().unwrap().to_string_lossy()
);
app.dialog()
.message(
"Database corrupted. A copy has been saved at: ".to_string()
+ original.to_str().unwrap(),
)
.title("Database corrupted")
.show(|_| {});
}
}
});
let menu = Menu::with_items(
app,
&[
&MenuItem::with_id(app, "open", "Open", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
/*
&MenuItem::with_id(app, "show_library", "Library", true, None::<&str>)?,
&MenuItem::with_id(app, "show_settings", "Settings", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
*/
&MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?,
],
)?;
run_on_tray(|| {
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
app.webview_windows().get("main").unwrap().show().unwrap();
}
"quit" => {
cleanup_and_exit(app, &app.state());
}
_ => {
warn!("menu event not handled: {:?}", event.id);
}
})
.build(app)
.expect("error while setting up tray menu");
});
{
let mut db_handle = borrow_db_mut_checked();
if let Some(original) = db_handle.prev_database.take() {
warn!(
"Database corrupted. Original file at {}",
original.canonicalize().unwrap().to_string_lossy()
);
app.dialog()
.message(
"Database corrupted. A copy has been saved at: ".to_string()
+ original.to_str().unwrap(),
)
.title("Database corrupted")
.show(|_| {});
}
}
Ok(())
})
.register_asynchronous_uri_scheme_protocol("object", move |_ctx, request, responder| {
@ -441,15 +279,21 @@ pub fn run() {
fetch_object(request, responder).await;
});
})
.register_asynchronous_uri_scheme_protocol("server", move |ctx, request, responder| {
let state: tauri::State<'_, Mutex<AppState>> = ctx.app_handle().state();
offline!(
state,
handle_server_proto,
handle_server_proto_offline,
request,
responder
);
.register_asynchronous_uri_scheme_protocol("server", |ctx, request, responder| {
tauri::async_runtime::block_on(async move {
let state = ctx
.app_handle()
.state::<tauri::State<'_, Mutex<AppState>>>();
offline!(
state,
handle_server_proto,
handle_server_proto_offline,
request,
responder
)
.await;
});
})
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {

View File

@ -0,0 +1,103 @@
use drop_errors::remote_access_error::RemoteAccessError;
use drop_native_library::collections::{Collection, Collections};
use drop_remote::{
auth::generate_authorization_header, cache::{cache_object, get_cached_object}, requests::{generate_url, make_authenticated_get}, utils::DROP_CLIENT_ASYNC
};
use serde_json::json;
#[tauri::command]
pub async fn fetch_collections(
hard_refresh: Option<bool>,
) -> Result<Collections, RemoteAccessError> {
let do_hard_refresh = hard_refresh.unwrap_or(false);
if !do_hard_refresh && let Ok(cached_response) = get_cached_object::<Collections>("collections")
{
return Ok(cached_response);
}
let response =
make_authenticated_get(generate_url(&["/api/v1/client/collection"], &[])?).await?;
let collections: Collections = response.json().await?;
cache_object("collections", &collections)?;
Ok(collections)
}
#[tauri::command]
pub async fn fetch_collection(collection_id: String) -> Result<Collection, RemoteAccessError> {
let response = make_authenticated_get(generate_url(
&["/api/v1/client/collection/", &collection_id],
&[],
)?)
.await?;
Ok(response.json().await?)
}
#[tauri::command]
pub async fn create_collection(name: String) -> Result<Collection, RemoteAccessError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(&["/api/v1/client/collection"], &[])?;
let response = client
.post(url)
.header("Authorization", generate_authorization_header())
.json(&json!({"name": name}))
.send()
.await?;
Ok(response.json().await?)
}
#[tauri::command]
pub async fn add_game_to_collection(
collection_id: String,
game_id: String,
) -> Result<(), RemoteAccessError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(&["/api/v1/client/collection", &collection_id, "entry"], &[])?;
client
.post(url)
.header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id}))
.send()
.await?;
Ok(())
}
#[tauri::command]
pub async fn delete_collection(collection_id: String) -> Result<bool, RemoteAccessError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(&["/api/v1/client/collection", &collection_id], &[])?;
let response = client
.delete(url)
.header("Authorization", generate_authorization_header())
.send()
.await?;
Ok(response.json().await?)
}
#[tauri::command]
pub async fn delete_game_in_collection(
collection_id: String,
game_id: String,
) -> Result<(), RemoteAccessError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(&["/api/v1/client/collection", &collection_id, "entry"], &[])?;
client
.delete(url)
.header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id}))
.send()
.await?;
Ok(())
}

View File

@ -0,0 +1,76 @@
use std::sync::Mutex;
use drop_database::{borrow_db_checked, models::data::GameVersion, runtime_models::Game};
use drop_errors::{library_error::LibraryError, remote_access_error::RemoteAccessError};
use drop_native_library::{library::{fetch_game_logic, fetch_game_logic_offline, fetch_game_version_options_logic, fetch_library_logic, fetch_library_logic_offline, get_current_meta, uninstall_game_logic, FetchGameStruct}, state::{GameStatusManager, GameStatusWithTransient}};
use tauri::AppHandle;
use crate::{AppState, offline};
#[tauri::command]
pub async fn fetch_library(
state: tauri::State<'_, Mutex<AppState>>,
hard_refresh: Option<bool>,
) -> Result<Vec<Game>, RemoteAccessError> {
offline!(
state,
fetch_library_logic,
fetch_library_logic_offline,
hard_refresh
)
.await
}
#[tauri::command]
pub async fn fetch_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
offline!(
state,
fetch_game_logic,
fetch_game_logic_offline,
game_id
)
.await
}
#[tauri::command]
pub fn fetch_game_status(id: String) -> GameStatusWithTransient {
let db_handle = borrow_db_checked();
GameStatusManager::fetch_state(&id, &db_handle)
}
#[tauri::command]
pub fn uninstall_game(game_id: String, app_handle: AppHandle) -> Result<(), LibraryError> {
let meta = match get_current_meta(&game_id) {
Some(data) => data,
None => return Err(LibraryError::MetaNotFound(game_id)),
};
uninstall_game_logic(meta, &app_handle);
Ok(())
}
#[tauri::command]
pub async fn fetch_game_version_options(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<Vec<GameVersion>, RemoteAccessError> {
let all_versions = fetch_game_version_options_logic(game_id).await?;
let state_lock = state.lock().unwrap();
let process_manager_lock = state_lock.process_manager.lock().unwrap();
let data: Vec<GameVersion> = all_versions
.into_iter()
.filter(|v| {
process_manager_lock
.valid_platform(&v.platform)
.unwrap()
})
.collect();
drop(process_manager_lock);
drop(state_lock);
Ok(data)
}

View File

@ -0,0 +1,80 @@
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use drop_database::{borrow_db_checked, models::data::GameDownloadStatus};
use drop_downloads::downloadable::Downloadable;
use drop_errors::application_download_error::ApplicationDownloadError;
use crate::AppState;
use super::download_agent::GameDownloadAgent;
#[tauri::command]
pub async fn download_game(
game_id: String,
game_version: String,
install_dir: usize,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), ApplicationDownloadError> {
let sender = { state.lock().unwrap().download_manager.get_sender().clone() };
let game_download_agent =
GameDownloadAgent::new_from_index(game_id.clone(), game_version.clone(), install_dir, sender).await?;
let game_download_agent =
Arc::new(Box::new(game_download_agent) as Box<dyn Downloadable + Send + Sync>);
state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent.clone())
.unwrap();
Ok(())
}
#[tauri::command]
pub async fn resume_download(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), ApplicationDownloadError> {
let s = borrow_db_checked()
.applications
.game_statuses
.get(&game_id)
.unwrap()
.clone();
let (version_name, install_dir) = match s {
GameDownloadStatus::Remote {} => unreachable!(),
GameDownloadStatus::SetupRequired { .. } => unreachable!(),
GameDownloadStatus::Installed { .. } => unreachable!(),
GameDownloadStatus::PartiallyInstalled {
version_name,
install_dir,
} => (version_name, install_dir),
};
let sender = state.lock().unwrap().download_manager.get_sender();
let parent_dir: PathBuf = install_dir.into();
let game_download_agent = Arc::new(Box::new(
GameDownloadAgent::new(
game_id,
version_name.clone(),
parent_dir.parent().unwrap().to_path_buf(),
sender,
)
.await?,
) as Box<dyn Downloadable + Send + Sync>);
state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent)
.unwrap();
Ok(())
}

View File

@ -1,26 +1,21 @@
use crate::auth::generate_authorization_header;
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
use crate::database::models::data::{
ApplicationTransientStatus, DownloadType, DownloadableMetadata,
};
use crate::download_manager::download_manager_frontend::{DownloadManagerSignal, DownloadStatus};
use crate::download_manager::downloadable::Downloadable;
use crate::download_manager::util::download_thread_control_flag::{
DownloadThreadControl, DownloadThreadControlFlag,
};
use crate::download_manager::util::progress_object::{ProgressHandle, ProgressObject};
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::games::downloads::validate::validate_game_chunk;
use crate::games::library::{on_game_complete, push_game_update, set_partially_installed};
use crate::games::state::GameStatusManager;
use crate::process::utils::get_disk_available;
use crate::remote::requests::make_request;
use crate::remote::utils::DROP_CLIENT_SYNC;
use drop_database::{borrow_db_checked, borrow_db_mut_checked};
use drop_database::drop_data::DropData;
use drop_database::models::data::{ApplicationTransientStatus, DownloadType, DownloadableMetadata};
use drop_downloads::download_manager_frontend::{DownloadManagerSignal, DownloadStatus};
use drop_downloads::downloadable::Downloadable;
use drop_downloads::util::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use drop_downloads::util::progress_object::{ProgressHandle, ProgressObject};
use drop_errors::application_download_error::ApplicationDownloadError;
use drop_errors::remote_access_error::RemoteAccessError;
use drop_native_library::library::{on_game_complete, push_game_update, set_partially_installed};
use drop_native_library::state::GameStatusManager;
use drop_process::utils::get_disk_available;
use drop_remote::auth::generate_authorization_header;
use drop_remote::requests::generate_url;
use drop_remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC};
use log::{debug, error, info, warn};
use rayon::ThreadPoolBuilder;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs::{OpenOptions, create_dir_all};
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
@ -31,16 +26,21 @@ use tauri::{AppHandle, Emitter};
#[cfg(target_os = "linux")]
use rustix::fs::{FallocateFlags, fallocate};
use super::download_logic::download_game_chunk;
use super::drop_data::DropData;
use crate::native_library::downloads::manifest::{DownloadBucket, DownloadContext, DownloadDrop, DropManifest, DropValidateContext, ManifestBody};
use crate::native_library::downloads::validate::validate_game_chunk;
use super::download_logic::download_game_bucket;
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,
contexts: Mutex<Vec<DropDownloadContext>>,
buckets: Mutex<Vec<DownloadBucket>>,
context_map: Mutex<HashMap<String, bool>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
@ -50,19 +50,21 @@ pub struct GameDownloadAgent {
}
impl GameDownloadAgent {
pub fn new_from_index(
pub async fn new_from_index(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Result<Self, ApplicationDownloadError> {
let db_lock = borrow_db_checked();
let base_dir = db_lock.applications.install_dirs[target_download_dir].clone();
drop(db_lock);
let base_dir = {
let db_lock = borrow_db_checked();
Self::new(id, version, base_dir, sender)
db_lock.applications.install_dirs[target_download_dir].clone()
};
Self::new(id, version, base_dir, sender).await
}
pub fn new(
pub async fn new(
id: String,
version: String,
base_dir: PathBuf,
@ -77,12 +79,14 @@ impl GameDownloadAgent {
let stored_manifest =
DropData::generate(id.clone(), version.clone(), data_base_dir_path.clone());
let context_lock = stored_manifest.contexts.lock().unwrap().clone();
let result = Self {
id,
version,
control_flag,
manifest: Mutex::new(None),
contexts: Mutex::new(Vec::new()),
buckets: Mutex::new(Vec::new()),
context_map: Mutex::new(HashMap::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
@ -90,7 +94,7 @@ impl GameDownloadAgent {
status: Mutex::new(DownloadStatus::Queued),
};
result.ensure_manifest_exists()?;
result.ensure_manifest_exists().await?;
let required_space = result
.manifest
@ -99,9 +103,15 @@ impl GameDownloadAgent {
.as_ref()
.unwrap()
.values()
.map(|e| e.lengths.iter().sum::<usize>())
.sum::<usize>()
as u64;
.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;
@ -117,26 +127,25 @@ impl GameDownloadAgent {
// Blocking
pub fn setup_download(&self, app_handle: &AppHandle) -> Result<(), ApplicationDownloadError> {
self.ensure_manifest_exists()?;
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)));
self.ensure_contexts()?;
if !self.check_manifest_exists() {
return Err(ApplicationDownloadError::NotInitialized);
}
self.ensure_buckets()?;
self.control_flag.set(DownloadThreadControlFlag::Go);
let mut db_lock = borrow_db_mut_checked();
db_lock.applications.transient_statuses.insert(
self.metadata(),
ApplicationTransientStatus::Downloading {
version_name: self.version.clone(),
},
);
push_game_update(
app_handle,
&self.metadata().id,
None,
GameStatusManager::fetch_state(&self.metadata().id, &db_lock),
);
Ok(())
}
@ -147,9 +156,7 @@ impl GameDownloadAgent {
info!("beginning download for {}...", self.metadata().id);
let res = self
.run()
.map_err(|()| ApplicationDownloadError::DownloadError);
let res = self.run().map_err(ApplicationDownloadError::Communication);
debug!(
"{} took {}ms to download",
@ -159,37 +166,43 @@ impl GameDownloadAgent {
res
}
pub fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
pub fn check_manifest_exists(&self) -> bool {
self.manifest.lock().unwrap().is_some()
}
pub async fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
if self.manifest.lock().unwrap().is_some() {
return Ok(());
}
self.download_manifest()
self.download_manifest().await
}
fn download_manifest(&self) -> Result<(), ApplicationDownloadError> {
let header = generate_authorization_header();
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(
&client,
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)],
|f| f.header("Authorization", header),
)
.map_err(ApplicationDownloadError::Communication)?
.send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
.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().unwrap(),
response.text().await.unwrap(),
),
));
}
let manifest_download: DropManifest = response.json().unwrap();
let manifest_download: DropManifest = response.json().await.unwrap();
if let Ok(mut manifest) = self.manifest.lock() {
*manifest = Some(manifest_download);
@ -201,20 +214,23 @@ impl GameDownloadAgent {
// Sets it up for both download and validate
fn setup_progress(&self) {
let contexts = self.contexts.lock().unwrap();
let buckets = self.buckets.lock().unwrap();
let length = contexts.len();
let chunk_count = buckets.iter().map(|e| e.drops.len()).sum();
let chunk_count = contexts.iter().map(|chunk| chunk.length).sum();
let total_length = buckets
.iter()
.map(|bucket| bucket.drops.iter().map(|e| e.length).sum::<usize>())
.sum();
self.progress.set_max(chunk_count);
self.progress.set_size(length);
self.progress.set_max(total_length);
self.progress.set_size(chunk_count);
self.progress.reset();
}
pub fn ensure_contexts(&self) -> Result<(), ApplicationDownloadError> {
if self.contexts.lock().unwrap().is_empty() {
self.generate_contexts()?;
pub fn ensure_buckets(&self) -> Result<(), ApplicationDownloadError> {
if self.buckets.lock().unwrap().is_empty() {
self.generate_buckets()?;
}
*self.context_map.lock().unwrap() = self.dropdata.get_contexts();
@ -222,14 +238,18 @@ impl GameDownloadAgent {
Ok(())
}
pub fn generate_contexts(&self) -> Result<(), ApplicationDownloadError> {
pub fn generate_buckets(&self) -> Result<(), ApplicationDownloadError> {
let manifest = self.manifest.lock().unwrap().clone().unwrap();
let game_id = self.id.clone();
let mut contexts = Vec::new();
let base_path = Path::new(&self.dropdata.base_path);
create_dir_all(base_path).unwrap();
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));
@ -244,42 +264,95 @@ impl GameDownloadAgent {
.truncate(false)
.open(path.clone())
.unwrap();
let mut running_offset = 0;
let mut file_running_offset = 0;
for (index, length) in chunk.lengths.iter().enumerate() {
contexts.push(DropDownloadContext {
file_name: raw_path.to_string(),
version: chunk.version_name.to_string(),
offset: running_offset,
index,
game_id: game_id.to_string(),
path: path.clone(),
checksum: chunk.checksums[index].clone(),
let drop = DownloadDrop {
filename: raw_path.to_string(),
start: file_running_offset,
length: *length,
checksum: chunk.checksums[index].clone(),
permissions: chunk.permissions,
});
running_offset += *length as u64;
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 running_offset > 0 && !already_exists {
let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset);
if file_running_offset > 0 && !already_exists {
let _ = fallocate(file, FallocateFlags::empty(), 0, file_running_offset as u64);
}
}
let existing_contexts = self.dropdata.get_completed_contexts();
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(
&contexts
&buckets
.iter()
.map(|x| (x.checksum.clone(), existing_contexts.contains(&x.checksum)))
.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)>>(),
);
*self.contexts.lock().unwrap() = contexts;
*self.buckets.lock().unwrap() = buckets;
Ok(())
}
fn run(&self) -> Result<bool, ()> {
fn run(&self) -> Result<bool, RemoteAccessError> {
self.setup_progress();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
@ -292,81 +365,110 @@ impl GameDownloadAgent {
.build()
.unwrap();
let buckets = self.buckets.lock().unwrap();
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();
let contexts = self.contexts.lock().unwrap();
for version in versions {
let download_context = DROP_CLIENT_SYNC
.post(generate_url(&["/api/v2/client/context"], &[]).unwrap())
.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 client = &DROP_CLIENT_SYNC.clone();
let context_map = self.context_map.lock().unwrap();
for (index, context) in contexts.iter().enumerate() {
let client = client.clone();
let completed_indexes = completed_indexes_loop_arc.clone();
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
if let Some(v) = context_map.get(&context.checksum)
&& *v
{
progress_handle.skip(context.length);
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 request = match make_request(
&client,
&["/api/v1/client/chunk"],
&[
("id", &context.game_id),
("version", &context.version),
("name", &context.file_name),
("chunk", &context.index.to_string()),
],
|r| r,
) {
Ok(request) => request,
Err(e) => {
sender
.send(DownloadManagerSignal::Error(
ApplicationDownloadError::Communication(e),
))
.unwrap();
continue;
}
};
let download_context = download_contexts
.get(&bucket.version)
.ok_or(RemoteAccessError::CorruptedState)
.unwrap();
scope.spawn(move |_| {
// 3 attempts
for i in 0..RETRY_COUNT {
let loop_progress_handle = progress_handle.clone();
match download_game_chunk(
context,
match download_game_bucket(
&bucket,
download_context,
&self.control_flag,
loop_progress_handle,
request.try_clone().unwrap(),
) {
Ok(true) => {
completed_indexes.push(context.checksum.clone());
for drop in bucket.drops {
completed_contexts.push(drop.checksum);
}
return;
}
Ok(false) => return,
Err(e) => {
warn!("game download agent error: {e}");
let retry = match &e {
ApplicationDownloadError::Communication(
_remote_access_error,
) => true,
ApplicationDownloadError::Checksum => true,
ApplicationDownloadError::Lock => true,
ApplicationDownloadError::IoError(_error_kind) => false,
ApplicationDownloadError::DownloadError => false,
ApplicationDownloadError::DiskFull(_, _) => false,
};
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.");
@ -390,14 +492,14 @@ impl GameDownloadAgent {
context_map_lock.values().filter(|x| **x).count()
};
let context_map_lock = self.context_map.lock().unwrap();
let contexts = contexts
let contexts = buckets
.iter()
.flat_map(|x| x.drops.iter().map(|e| e.checksum.clone()))
.map(|x| {
(
x.checksum.clone(),
context_map_lock.get(&x.checksum).copied().unwrap_or(false),
)
let completed = context_map_lock.get(&x).unwrap_or(&false);
(x, *completed)
})
.collect::<Vec<(String, bool)>>();
drop(context_map_lock);
@ -408,10 +510,11 @@ impl GameDownloadAgent {
// If there are any contexts left which are false
if !contexts.iter().all(|x| x.1) {
info!(
"download agent for {} exited without completing ({}/{})",
"download agent for {} exited without completing ({}/{}) ({} buckets)",
self.id.clone(),
completed_lock_len,
contexts.len(),
buckets.len()
);
return Ok(false);
}
@ -424,31 +527,30 @@ impl GameDownloadAgent {
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(),
ApplicationTransientStatus::Validating {
version_name: self.version.clone(),
},
);
push_game_update(
app_handle,
&self.metadata().id,
None,
GameStatusManager::fetch_state(&self.metadata().id, &db_lock),
);
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 contexts = self.contexts.lock().unwrap();
let buckets = self.buckets.lock().unwrap();
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;
debug!(
"validating game: {} with {} threads",
self.dropdata.game_id, max_download_threads
);
info!("{} validation contexts", contexts.len());
let pool = ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
@ -532,8 +634,17 @@ impl Downloadable for GameDownloadAgent {
}
}
fn on_initialised(&self, _app_handle: &tauri::AppHandle) {
fn on_queued(&self, app_handle: &tauri::AppHandle) {
*self.status.lock().unwrap() = DownloadStatus::Queued;
let mut db_lock = borrow_db_mut_checked();
let status = ApplicationTransientStatus::Queued {
version_name: self.version.clone(),
};
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
push_game_update(app_handle, &self.id, None, (None, Some(status)));
}
fn on_error(&self, app_handle: &tauri::AppHandle, error: &ApplicationDownloadError) {
@ -542,13 +653,20 @@ impl Downloadable for GameDownloadAgent {
.emit("download_error", error.to_string())
.unwrap();
error!("error while managing download: {error}");
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) {
@ -561,15 +679,8 @@ impl Downloadable for GameDownloadAgent {
}
fn on_cancelled(&self, app_handle: &tauri::AppHandle) {
info!("cancelled {}", self.id);
self.cancel(app_handle);
/*
on_game_incomplete(
&self.metadata(),
self.dropdata.base_path.to_string_lossy().to_string(),
app_handle,
)
.unwrap();
*/
}
fn status(&self) -> DownloadStatus {

View File

@ -0,0 +1,273 @@
use drop_downloads::util::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use drop_downloads::util::progress_object::ProgressHandle;
use drop_errors::application_download_error::ApplicationDownloadError;
use drop_errors::drop_server_error::ServerError;
use drop_errors::remote_access_error::RemoteAccessError;
use drop_remote::auth::generate_authorization_header;
use drop_remote::requests::generate_url;
use drop_remote::utils::DROP_CLIENT_SYNC;
use log::{debug, info, warn};
use md5::{Context, Digest};
use reqwest::blocking::Response;
use std::fs::{Permissions, set_permissions};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use std::time::Instant;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write},
path::PathBuf,
};
use crate::native_library::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.compute())
}
}
// 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"))
.unwrap();
let mut remaining = drop.length;
if drop.start != 0 {
destination.seek(SeekFrom::Start(drop.start.try_into().unwrap()))?;
}
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::<ServerError>(&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()
.unwrap();
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);
};
if drop.length != length {
warn!(
"for {}, expected {}, got {} ({})",
drop.filename, drop.length, raw_length, length
);
return Err(ApplicationDownloadError::DownloadError);
}
}
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

@ -0,0 +1,98 @@
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,6 +1,5 @@
pub mod commands;
pub mod download_agent;
mod download_logic;
pub mod drop_data;
mod manifest;
pub mod validate;

View File

@ -3,28 +3,21 @@ use std::{
io::{self, BufWriter, Read, Seek, SeekFrom, Write},
};
use drop_downloads::util::{download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, progress_object::ProgressHandle};
use drop_errors::application_download_error::ApplicationDownloadError;
use log::debug;
use md5::Context;
use crate::{
download_manager::
util::{
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::ProgressHandle,
}
,
error::application_download_error::ApplicationDownloadError,
games::downloads::manifest::DropDownloadContext,
};
use crate::native_library::downloads::manifest::DropValidateContext;
pub fn validate_game_chunk(
ctx: &DropDownloadContext,
ctx: &DropValidateContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> Result<bool, ApplicationDownloadError> {
debug!(
"Starting chunk validation {}, {}, {} #{}",
ctx.file_name, ctx.index, ctx.offset, ctx.checksum
ctx.path.display(), ctx.index, ctx.offset, ctx.checksum
);
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
@ -38,7 +31,7 @@ pub fn validate_game_chunk(
if ctx.offset != 0 {
source
.seek(SeekFrom::Start(ctx.offset))
.seek(SeekFrom::Start(ctx.offset.try_into().unwrap()))
.expect("Failed to seek to file offset");
}

View File

@ -0,0 +1,3 @@
pub mod collection_commands;
pub mod commands;
pub mod downloads;

View File

@ -1,6 +1,10 @@
use std::sync::Mutex;
use crate::{error::process_error::ProcessError, AppState};
use drop_database::borrow_db_mut_checked;
use drop_errors::{library_error::LibraryError, process_error::ProcessError};
use serde::Deserialize;
use crate::AppState;
#[tauri::command]
pub fn launch_game(
@ -16,7 +20,7 @@ pub fn launch_game(
// download_type: DownloadType::Game,
//};
match process_manager_lock.launch_process(id, &state_lock) {
match process_manager_lock.launch_process(id, state_lock.process_manager) {
Ok(()) => {}
Err(e) => return Err(e),
}
@ -48,3 +52,48 @@ pub fn open_process_logs(
let mut process_manager_lock = state_lock.process_manager.lock().unwrap();
process_manager_lock.open_process_logs(game_id)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendGameOptions {
pub launch_string: String,
}
#[tauri::command]
pub fn update_game_configuration(
game_id: String,
options: FrontendGameOptions,
) -> Result<(), LibraryError> {
let mut handle = borrow_db_mut_checked();
let installed_version = handle
.applications
.installed_game_version
.get(&game_id)
.ok_or(LibraryError::MetaNotFound(game_id))?;
let id = installed_version.id.clone();
let version = installed_version.version.clone().unwrap();
let mut existing_configuration = handle
.applications
.game_versions
.get(&id)
.unwrap()
.get(&version)
.unwrap()
.clone();
// Add more options in here
existing_configuration.launch_command_template = options.launch_string;
// Add no more options past here
handle
.applications
.game_versions
.get_mut(&id)
.unwrap()
.insert(version.to_string(), existing_configuration);
Ok(())
}

View File

@ -1,5 +1 @@
pub mod commands;
pub mod process_manager;
pub mod process_handlers;
pub mod format;
pub mod utils;
pub mod commands;

View File

@ -1,221 +0,0 @@
use std::{collections::HashMap, env, sync::Mutex};
use chrono::Utc;
use droplet_rs::ssl::sign_nonce;
use gethostname::gethostname;
use log::{debug, error, warn};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager};
use url::Url;
use crate::{
database::{
db::{borrow_db_checked, borrow_db_mut_checked},
models::data::DatabaseAuth,
}, error::{drop_server_error::DropServerError, remote_access_error::RemoteAccessError}, remote::utils::DROP_CLIENT_SYNC, AppState, AppStatus, User
};
use super::{
cache::{cache_object, get_cached_object},
requests::make_request,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CapabilityConfiguration {}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InitiateRequestBody {
name: String,
platform: String,
capabilities: HashMap<String, CapabilityConfiguration>,
mode: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct HandshakeRequestBody {
client_id: String,
token: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct HandshakeResponse {
private: String,
certificate: String,
id: String,
}
pub fn generate_authorization_header() -> String {
let certs = {
let db = borrow_db_checked();
db.auth.clone().unwrap()
};
let nonce = Utc::now().timestamp_millis().to_string();
let signature = sign_nonce(certs.private, nonce.clone()).unwrap();
format!("Nonce {} {} {}", certs.client_id, nonce, signature)
}
pub fn fetch_user() -> Result<User, RemoteAccessError> {
let header = generate_authorization_header();
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(&client, &["/api/v1/client/user"], &[], |f| {
f.header("Authorization", header)
})?
.send()?;
if response.status() != 200 {
let err: DropServerError = response.json()?;
warn!("{err:?}");
if err.status_message == "Nonce expired" {
return Err(RemoteAccessError::OutOfSync);
}
return Err(RemoteAccessError::InvalidResponse(err));
}
response.json::<User>().map_err(std::convert::Into::into)
}
fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAccessError> {
let path_chunks: Vec<&str> = path.split('/').collect();
if path_chunks.len() != 3 {
app.emit("auth/failed", ()).unwrap();
return Err(RemoteAccessError::HandshakeFailed(
"failed to parse token".to_string(),
));
}
let base_url = {
let handle = borrow_db_checked();
Url::parse(handle.base_url.as_str())?
};
let client_id = path_chunks.get(1).unwrap();
let token = path_chunks.get(2).unwrap();
let body = HandshakeRequestBody {
client_id: (*client_id).to_string(),
token: (*token).to_string(),
};
let endpoint = base_url.join("/api/v1/client/auth/handshake")?;
let client = DROP_CLIENT_SYNC.clone();
let response = client.post(endpoint).json(&body).send()?;
debug!("handshake responsded with {}", response.status().as_u16());
if !response.status().is_success() {
return Err(RemoteAccessError::InvalidResponse(response.json()?));
}
let response_struct: HandshakeResponse = response.json()?;
{
let mut handle = borrow_db_mut_checked();
handle.auth = Some(DatabaseAuth {
private: response_struct.private,
cert: response_struct.certificate,
client_id: response_struct.id,
web_token: None, // gets created later
});
}
let web_token = {
let header = generate_authorization_header();
let token = client
.post(base_url.join("/api/v1/client/user/webtoken").unwrap())
.header("Authorization", header)
.send()
.unwrap();
token.text().unwrap()
};
let mut handle = borrow_db_mut_checked();
let mut_auth = handle.auth.as_mut().unwrap();
mut_auth.web_token = Some(web_token);
Ok(())
}
pub fn recieve_handshake(app: AppHandle, path: String) {
// Tell the app we're processing
app.emit("auth/processing", ()).unwrap();
let handshake_result = recieve_handshake_logic(&app, path);
if let Err(e) = handshake_result {
warn!("error with authentication: {e}");
app.emit("auth/failed", e.to_string()).unwrap();
return;
}
let app_state = app.state::<Mutex<AppState>>();
let mut state_lock = app_state.lock().unwrap();
let (app_status, user) = setup();
state_lock.status = app_status;
state_lock.user = user;
drop(state_lock);
app.emit("auth/finished", ()).unwrap();
}
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.into_string().unwrap()),
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 fn setup() -> (AppStatus, Option<User>) {
let data = borrow_db_checked();
let auth = data.auth.clone();
drop(data);
if auth.is_some() {
let user_result = match fetch_user() {
Ok(data) => data,
Err(RemoteAccessError::FetchError(_)) => {
let user = get_cached_object::<User>("user").unwrap();
return (AppStatus::Offline, Some(user));
}
Err(_) => return (AppStatus::SignedInNeedsReauth, None),
};
cache_object("user", &user_result).unwrap();
return (AppStatus::SignedIn, Some(user_result));
}
(AppStatus::SignedOut, None)
}

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