Compare commits

...
Sign in to create a new pull request.

59 commits

Author SHA1 Message Date
quexeky
a154d1ea11
fix(game downloads): Accidentally was attempting to lock onto something that was already in scope
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 18:43:29 +11:00
quexeky
cba7d8e523
Merge branch 'develop' into generic-download-manager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 18:19:21 +11:00
Aden Lindsay
02f8591a60
feat(settings): add debug page
* Create debug.rs

* Update settings.vue to add tab for debug

* Update main.scss to add light theme

* Update interface.vue to add light mode

* Create debug.vue

* Update debug.vue too add open log button

* Update lib.rs

* Update debug.rs

* Update debug.rs

* Update lib.rs

* Update lib.rs

* Update debug.rs

* Update debug.vue

* fix(debug): refactor and cleanup

* revert(theme): revert light theming

---------

Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
2025-01-05 17:56:33 +11:00
quexeky
d58348434c
perf(game downloads): Moved some variable declarations outside of the spawned download thread
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 17:37:10 +11:00
quexeky
4a5f416d95
chore(README): Updated README.md
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-05 09:14:34 +11:00
quexeky
a2bcf5325f
feat(database): Added database corruption dialog
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-04 22:29:24 +11:00
quexeky
6b029041ed
feat(recovery): Added database recovery
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-04 21:08:22 +11:00
quexeky
5c12a29f66
fix(kill game): Re-enabled killing games
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-04 18:46:11 +11:00
quexeky
df922f8580
refactor(downloads): Moved all files relevant to game downloads to their own directory
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-04 18:35:06 +11:00
quexeky
d17ad6fcc9
fix(uninstalling): Re-enabled uninstalling apps
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-04 16:27:30 +11:00
quexeky
9e7208edae
fix(game launcher): Renamed game_id to id
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-04 15:56:22 +11:00
quexeky
bed2df49f8
feat(download manager): Added generic download manager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-04 15:47:14 +11:00
quexeky
a4a5839a3a
refactor(download manager): Removed Arc requirement for DownloadableMetadata
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-02 12:24:22 +11:00
quexeky
352926af6c
refactor(download manager): Fully separate & generic download manager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-02 12:16:17 +11:00
quexeky
ca0fa8ffcb
refactor(download manager): Removed all references to anything outside of the DownloadManager
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-02 08:18:29 +11:00
Aden Lindsay
0a0d9d6294
feat(account settings): Add signout functionality (#16)
* Create account.vue with logout button

* Update auth.rs to add signout command

* Update lib.rs to pass sign_out command to frontend
2025-01-01 13:23:18 +11:00
quexeky
adf9907dbf
chore(download manager): Added manage_go_signal command
Signed-off-by: quexeky <git@quexeky.dev>
2025-01-01 13:21:27 +11:00
quexeky
267a2beddf
chore(download manager): Added manage_queue_signal
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-31 17:00:10 +11:00
quexeky
e7b1465478
chore(tool manager): Added ToolDownloadAgent
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-31 13:18:00 +11:00
quexeky
8d46a94f90
Revert "refactor(download manager): Moved manifest and stored_manifest to download_manager"
This reverts commit 8db2393346.
2024-12-31 13:05:51 +11:00
quexeky
8db2393346
refactor(download manager): Moved manifest and stored_manifest to download_manager
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-31 12:53:31 +11:00
quexeky
61b3271559
chore(download manager): Ensure that Downloadable is also send and sync
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-31 12:20:42 +11:00
quexeky
5b5d6092fe
chore(download manager): Some easy cleanup of the download manager
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-31 12:16:57 +11:00
quexeky
972ebd6ab6
refactor(download manager): Renamed GameDonwloadError to ApplicationDownloadError and moved
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-31 12:12:17 +11:00
DecDuck
a17311a88d
fix(download manager): fix incorrect error assumptions & update types 2024-12-31 00:08:05 +11:00
quexeky
fb20c56d45
chore(download manager): Renamed most instances of "game" outside of actual game downloads
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 21:29:14 +11:00
quexeky
d9136b6ca8
refactor(download manager): Added Downloadable trait and replaced references to GameDownloadAgent
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 20:30:38 +11:00
DecDuck
472eb1d435
squash(autostart): added adenmgb's autostart feature
Squashed commit of the following:

commit 085cd9481dee748ee84c1a8f9dcd8ef0b01105ab
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 16:29:41 2024 +1030

    Update lib.rs for the DB sync of autostart

commit 86f2fb19bde3933ec6b5cd82701de0d306121659
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 16:29:13 2024 +1030

    Update db.rs to accomidate the settings sync

commit ece11e7581c9aef55588b2e829379b1224021a45
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 16:27:48 2024 +1030

    Update autostart.rs to include DB

commit 7ea8a24fdc2ff98379694ce8e347a40fcfd5fea3
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:17:38 2024 +1030

    Add files via upload

commit af2f232d94fa449d9e20df737ce81ebded87fd50
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:17:09 2024 +1030

    Delete src-tauri/Cargo.toml

commit 5d27b65612457de6eb30835d5423b9d96fd5a596
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:15:42 2024 +1030

    Add files via upload

commit 2eea7b97a876e23cc0d6daec37f1b75af9bf3ae2
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:15:31 2024 +1030

    Delete src-tauri/src/lib.rs

commit 9a635a10d1340f86c74812113284b115b34b9bbe
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:14:49 2024 +1030

    Add files via upload

commit 2fb049531a082fbdd217aba694819b7a3f954a55
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:13:37 2024 +1030

    Add files via upload

commit ea1be4d7505a9ab16bda338491c0ec313d0bc586
Author: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
Date:   Mon Dec 30 15:13:20 2024 +1030

    Delete pages/settings/index.vue
2024-12-30 17:31:03 +11:00
quexeky
e91f79bf57
refactor(download manager): Moved download manager to separate directory
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 17:25:17 +11:00
quexeky
22f6d3083f
chore(tool manager): Progress on adding tools
Going to try changing around the download manager to take a generic trait rather than specifically for game downloads

Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 17:09:31 +11:00
DecDuck
6b96e408b2
feat(process): shared child with stop command 2024-12-30 15:37:29 +11:00
quexeky
c3f62222fe
fix(deep links): Re-enabled deep links
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 14:18:42 +11:00
quexeky
01e6162527
feat(process manager): Game kill tauri command
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 13:50:26 +11:00
quexeky
88b2505e71
feat(Compat): Implemented spawning with umu (using umu-wrapper-lib)
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-30 13:12:27 +11:00
DecDuck
95f2174f8d
fix(scrollbars): fix ugly scrollbars on edge webview 2024-12-29 20:45:45 +11:00
DecDuck
7c90d2b8fd
fix(process): fix poorly designed parsing for executables with spaces 2024-12-28 10:13:26 +11:00
DecDuck
d7b0302bdd
fix(library page): fix install button 2024-12-27 16:33:04 +11:00
DecDuck
3ccd44466f
merge(SeeThruHead): add nvm rc
Signed-off-by: DecDuck <declanahofmeyr@gmail.com>
2024-12-27 14:33:23 +11:00
Aden Lindsay
93b8b83c20
Add files via upload 2024-12-27 14:32:56 +11:00
Aden Lindsay
1861659daa
Delete pages/library.vue 2024-12-27 14:32:56 +11:00
DecDuck
327628b780
fix(install flow): clear stale data before requesting new 2024-12-27 14:32:56 +11:00
DecDuck
f4ac1c87cd
feat(install modal): add note about more install dirs 2024-12-27 14:32:56 +11:00
DecDuck
03fa3646fa
merge(adenmgb): library ui
Signed-off-by: DecDuck <declanahofmeyr@gmail.com>
2024-12-27 14:30:38 +11:00
seethruhead
a881d8e248 adds nvm rc! 2024-12-26 22:21:26 -05:00
Aden Lindsay
dcb2c0f004
Add files via upload 2024-12-27 13:40:24 +10:30
Aden Lindsay
c722a54132
Delete pages/library.vue 2024-12-27 13:39:50 +10:30
DecDuck
e72662c4a8
fix(install flow): clear stale data before requesting new 2024-12-27 14:04:55 +11:00
DecDuck
139bc0ca36
feat(install modal): add note about more install dirs 2024-12-27 14:03:06 +11:00
DecDuck
949acfc161
feat(auth): offer manual signin 2024-12-27 13:07:10 +11:00
DecDuck
9af0d08875
fix(auth initiate): add better error message 2024-12-27 12:15:30 +11:00
quexeky
dcb1564568
chore: Ran cargo clippy
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-27 11:58:37 +11:00
DecDuck
1f899ec349
feat(download ui): add speed and time remaining information
closes #7

Co-authored-by: AdenMGB <140392385+AdenMGB@users.noreply.github.com>
2024-12-27 11:53:17 +11:00
DecDuck
6a8d0af87d
fix(sign on): add message about nonce expiration 2024-12-26 21:30:08 +11:00
DecDuck
21835858f1
feat(downloads): lockless tracking of downloaded chunks 2024-12-26 17:41:10 +11:00
DecDuck
a135b1321c
feat(process): better process management, including running state 2024-12-26 17:19:19 +11:00
DecDuck
ad92dbec08
feat(errors): better download manager errors + modal 2024-12-26 12:56:54 +11:00
DecDuck
85a08990c3
chore(metadata): update metadata 2024-12-26 12:56:26 +11:00
DecDuck
dd7f5675d8
feat(game): game uninstalling & partial compat 2024-12-26 11:59:26 +11:00
DecDuck
9ea2aa4997
chore(process manager): refactor for generic way to implement cross
platform launchers
2024-12-25 23:05:10 +11:00
67 changed files with 3085 additions and 1565 deletions

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
23

View file

@ -2,6 +2,19 @@
Drop app is the companion app for [Drop](https://github.com/Drop-OSS/drop). It uses a Tauri base with Nuxt 3 + TailwindCSS on top of it, so we can re-use components from the web UI.
## Running
Before setting up the drop app, be sure that you have a server set up.
The instructions for this can be found on the [Drop Wiki](https://wiki.droposs.org/guides/quickstart.html)
## Current features
Currently supported are the following features:
- Signin (with custom server)
- Database registering & recovery
- Dynamic library fetching from server
- Installing & uninstalling games
- Download progress monitoring
- Launching / playing games
## Development
Install dependencies with `yarn`
@ -10,7 +23,7 @@ Run the app in development with `yarn tauri dev`. NVIDIA users on Linux, use she
To manually specify the logging level, add the environment variable `RUST_LOG=[debug, info, warn, error]` to `yarn tauri dev`:
e.g. `RUST_LOG=debug yarn taudi dev`
e.g. `RUST_LOG=debug yarn tauri dev`
## Contributing
Check the original [Drop repo](https://github.com/Drop-OSS/drop/blob/main/CONTRIBUTING.md) for contributing guidelines.

View file

@ -6,7 +6,7 @@
</template>
<script setup lang="ts">
import "~/composables/queue";
import "~/composables/downloads.js";
import { invoke } from "@tauri-apps/api/core";
import { AppStatus } from "~/types";
@ -20,10 +20,10 @@ import {
const router = useRouter();
const state = useAppState();
state.value = await invoke("fetch_state");
state.value = JSON.parse(await invoke("fetch_state"));
router.beforeEach(async () => {
state.value = await invoke("fetch_state");
state.value = JSON.parse(await invoke("fetch_state"));
});
setupHooks();

View file

@ -60,3 +60,25 @@ $helvetica: (
src: url("/fonts/inter/InterVariable-Italic.ttf");
font-style: italic;
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: 4px;
scrollbar-color: #52525b #00000000;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 4px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #52525b;
border-radius: 10px;
border: 3px solid #52525b;
}

View file

@ -1,52 +1,76 @@
<template>
<button
type="button"
@click="() => buttonActions[props.status.type]()"
:class="[
<div class="inline-flex divide-x divide-zinc-900">
<button type="button" @click="() => buttonActions[props.status.type]()" :class="[
styles[props.status.type],
'inline-flex uppercase font-display items-center gap-x-2 rounded-md px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]"
>
<component
:is="buttonIcons[props.status.type]"
class="-mr-0.5 size-5"
aria-hidden="true"
/>
{{ buttonNames[props.status.type] }}
</button>
showDropdown ? 'rounded-l-md' : 'rounded-md',
'inline-flex uppercase font-display items-center gap-x-2 px-4 py-3 text-md font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
]">
<component :is="buttonIcons[props.status.type]" class="-mr-0.5 size-5" aria-hidden="true" />
{{ buttonNames[props.status.type] }}
</button>
<Menu v-if="showDropdown" as="div" class="relative inline-block text-left grow">
<div class="h-full">
<MenuButton :class="[
styles[props.status.type],
'inline-flex w-full h-full justify-center items-center rounded-r-md px-1 py-2 text-sm font-semibold shadow-sm'
]">
<ChevronDownIcon class="size-5" aria-hidden="true" />
</MenuButton>
</div>
<transition enter-active-class="transition ease-out duration-100" enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100" leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100" leave-to-class="transform opacity-0 scale-95">
<MenuItems
class="absolute right-0 z-50 mt-2 w-32 origin-top-right rounded-md bg-zinc-900 shadow-lg ring-1 ring-zinc-100/5 focus:outline-none">
<div class="py-1">
<MenuItem v-slot="{ active }">
<button @click="() => emit('uninstall')"
:class="[active ? 'bg-zinc-800 text-zinc-100 outline-none' : 'text-zinc-400', 'w-full block px-4 py-2 text-sm inline-flex justify-between']">Uninstall
<TrashIcon class="size-5" />
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup lang="ts">
import {
ArrowDownTrayIcon,
ChevronDownIcon,
PlayIcon,
QueueListIcon,
TrashIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import { GameStatusEnum, type GameStatus } from "~/types.js";
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
const props = defineProps<{ status: GameStatus }>();
const emit = defineEmits<{
(e: "install"): void;
(e: "play"): void;
(e: "launch"): void;
(e: "queue"): void;
(e: "uninstall"): void;
(e: "kill"): void;
}>();
const showDropdown = computed(() => props.status.type === GameStatusEnum.Installed || props.status.type === GameStatusEnum.SetupRequired);
const styles: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Remote]:
"bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600",
[GameStatusEnum.Queued]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.Downloading]:
"bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.SetupRequired]:
"bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600",
[GameStatusEnum.Installed]:
"bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600",
[GameStatusEnum.Updating]: "",
[GameStatusEnum.Uninstalling]: "",
[GameStatusEnum.Remote]: "bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600",
[GameStatusEnum.Queued]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.Downloading]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.SetupRequired]: "bg-yellow-600 text-white hover:bg-yellow-500 focus-visible:outline-yellow-600",
[GameStatusEnum.Installed]: "bg-green-600 text-white hover:bg-green-500 focus-visible:outline-green-600",
[GameStatusEnum.Updating]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.Uninstalling]: "bg-zinc-800 text-white hover:bg-zinc-700 focus-visible:outline-zinc-700",
[GameStatusEnum.Running]: "bg-zinc-800 text-white focus-visible:outline-zinc-700"
};
const buttonNames: { [key in GameStatusEnum]: string } = {
@ -57,6 +81,7 @@ const buttonNames: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "Play",
[GameStatusEnum.Updating]: "Updating",
[GameStatusEnum.Uninstalling]: "Uninstalling",
[GameStatusEnum.Running]: "Stop"
};
const buttonIcons: { [key in GameStatusEnum]: Component } = {
@ -67,15 +92,17 @@ const buttonIcons: { [key in GameStatusEnum]: Component } = {
[GameStatusEnum.Installed]: PlayIcon,
[GameStatusEnum.Updating]: ArrowDownTrayIcon,
[GameStatusEnum.Uninstalling]: TrashIcon,
[GameStatusEnum.Running]: PlayIcon
};
const buttonActions: { [key in GameStatusEnum]: () => void } = {
[GameStatusEnum.Remote]: () => emit("install"),
[GameStatusEnum.Queued]: () => emit("queue"),
[GameStatusEnum.Downloading]: () => emit("queue"),
[GameStatusEnum.SetupRequired]: () => {},
[GameStatusEnum.Installed]: () => emit("play"),
[GameStatusEnum.SetupRequired]: () => emit("launch"),
[GameStatusEnum.Installed]: () => emit("launch"),
[GameStatusEnum.Updating]: () => emit("queue"),
[GameStatusEnum.Uninstalling]: () => {},
[GameStatusEnum.Uninstalling]: () => { },
[GameStatusEnum.Running]: () => emit("kill")
};
</script>

View file

@ -42,6 +42,31 @@
</span>
</button>
<div class="mt-5" v-if="offerManual">
<h1 class="text-zinc-100 font-semibold">Having trouble?</h1>
<p class="mt-1 text-zinc-400 text-sm">
You can manually enter the token from your web browser.
</p>
<div class="inline-flex gap-x-1 mt-2 w-full">
<input
id="token"
name="token"
type="text"
autocomplete="token"
required
class="grow block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
v-model="manualToken"
/>
<LoadingButton
:loading="manualLoading"
@click="() => continueManual_wrapper()"
class="w-fit"
>
Submit
</LoadingButton>
</div>
</div>
<div v-if="error" class="mt-5 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
@ -101,6 +126,10 @@ import { invoke } from "@tauri-apps/api/core";
const loading = ref(false);
const error = ref<string | undefined>();
const offerManual = ref(false);
const manualToken = ref("");
const manualLoading = ref(false);
async function auth() {
await invoke("auth_initiate");
}
@ -111,5 +140,23 @@ function authWrapper_wrapper() {
loading.value = false;
error.value = e;
});
setTimeout(() => {
offerManual.value = true;
}, 10000);
}
async function continueManual() {
await invoke("manual_recieve_handshake", { token: manualToken.value });
}
function continueManual_wrapper() {
loading.value = true;
continueManual()
.catch((e) => {
error.value = e;
})
.finally(() => {
loading.value = false;
});
}
</script>

View file

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

28
composables/downloads.ts Normal file
View file

@ -0,0 +1,28 @@
import { listen } from "@tauri-apps/api/event";
import type { DownloadableMetadata } from "~/types";
export type QueueState = {
queue: Array<{ meta: DownloadableMetadata; status: string; progress: number | null }>;
status: string;
};
export type StatsState = {
speed: number; // Bytes per second
time: number; // Seconds,
};
export const useQueueState = () =>
useState<QueueState>("queue", () => ({ queue: [], status: "Unknown" }));
export const useStatsState = () =>
useState<StatsState>("stats", () => ({ speed: 0, time: 0 }));
listen("update_queue", (event) => {
const queue = useQueueState();
queue.value = event.payload as QueueState;
});
listen("update_stats", (event) => {
const stats = useStatsState();
stats.value = event.payload as StatsState;
});

View file

@ -13,6 +13,7 @@ export type SerializedGameStatus = [
];
const parseStatus = (status: SerializedGameStatus): GameStatus => {
console.log(status);
if (status[0]) {
return {
type: status[0].type,
@ -28,28 +29,29 @@ const parseStatus = (status: SerializedGameStatus): GameStatus => {
}
};
export const useGame = async (id: string) => {
if (!gameRegistry[id]) {
export const useGame = async (gameId: string) => {
if (!gameRegistry[gameId]) {
const data: { game: Game; status: SerializedGameStatus } = await invoke(
"fetch_game",
{
id,
gameId,
}
);
gameRegistry[id] = data.game;
if (!gameStatusRegistry[id]) {
gameStatusRegistry[id] = ref(parseStatus(data.status));
gameRegistry[gameId] = data.game;
if (!gameStatusRegistry[gameId]) {
gameStatusRegistry[gameId] = ref(parseStatus(data.status));
listen(`update_game/${id}`, (event) => {
listen(`update_game/${gameId}`, (event) => {
const payload: {
status: SerializedGameStatus;
} = event.payload as any;
gameStatusRegistry[id].value = parseStatus(payload.status);
console.log(payload.status);
gameStatusRegistry[gameId].value = parseStatus(payload.status);
});
}
}
const game = gameRegistry[id];
const status = gameStatusRegistry[id];
const game = gameRegistry[gameId];
const status = gameStatusRegistry[gameId];
return { game, status };
};
};

View file

@ -1,13 +0,0 @@
import { listen } from "@tauri-apps/api/event";
export type QueueState = {
queue: Array<{ id: string; status: string, progress: number | null }>;
};
export const useQueueState = () =>
useState<QueueState>("queue", () => ({ queue: [] }));
listen("update_queue", (event) => {
const queue = useQueueState();
queue.value = event.payload as QueueState;
});

View file

@ -1,4 +1,5 @@
import { listen } from "@tauri-apps/api/event";
import { data } from "autoprefixer";
import { AppStatus, type AppState } from "~/types";
export function setupHooks() {
@ -18,6 +19,20 @@ export function setupHooks() {
router.push("/store");
});
listen("download_error", (event) => {
createModal(
ModalType.Notification,
{
title: "Drop encountered an error while downloading",
description: `Drop encountered an error while downloading your game: "${(
event.payload as unknown as string
).toString()}"`,
buttonText: "Close"
},
(e, c) => c()
);
});
/*
document.addEventListener("contextmenu", (event) => {

View file

@ -18,9 +18,9 @@
"@tauri-apps/api": ">=2.0.0",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-shell": ">=2.0.0",
"markdown-it": "^14.1.0",
"moment": "^2.30.1",
"nuxt": "^3.13.0",
"scss": "^0.2.4",
"vue": "latest",

72
pages/account.vue Normal file
View file

@ -0,0 +1,72 @@
<template>
<div class="mx-auto max-w-7xl px-8">
<div class="border-b border-zinc-700 py-5">
<h3 class="text-base font-semibold font-display leading-6 text-zinc-100">
Account
</h3>
</div>
<div class="mt-5">
<div class="divide-y divide-zinc-700">
<div class="py-6">
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<div>
<h3 class="text-sm font-medium leading-6 text-zinc-100">Sign out</h3>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Sign out of your Drop account on this device
</p>
</div>
<button
@click="signOut"
type="button"
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
>
Sign out
</button>
</div>
<div v-if="error" class="rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { listen } from '@tauri-apps/api/event'
import { useRouter } from '#imports'
import { XCircleIcon } from "@heroicons/vue/16/solid";
const router = useRouter()
const error = ref<string | null>(null)
// Listen for auth events
onMounted(async () => {
await listen('auth/signedout', () => {
router.push('/auth/signedout')
})
})
async function signOut() {
try {
error.value = null
await invoke('sign_out')
} catch (e) {
error.value = `Failed to sign out: ${e}`
}
}
</script>

View file

@ -6,29 +6,22 @@
v-for="(nav, navIdx) in navigation"
:key="nav.route"
:class="[
'transition group rounded flex justify-between gap-x-6 py-2 px-3',
navIdx === currentNavigationIndex ? 'bg-zinc-900' : '',
'transition-all duration-200 rounded-lg flex items-center py-1.5 px-3',
navIdx === currentNavigationIndex
? 'bg-zinc-800 text-zinc-100'
: 'bg-zinc-900/50 text-zinc-400 hover:bg-zinc-800/70 hover:text-zinc-300',
]"
:href="nav.route"
>
<div class="flex items-center min-w-0 gap-x-2">
<div class="flex items-center w-full gap-x-3">
<img
class="h-5 w-auto flex-none object-cover rounded-sm bg-zinc-900"
class="h-8 w-10 flex-none object-cover bg-zinc-900"
:src="icons[navIdx]"
alt=""
/>
<div class="min-w-0 flex-auto">
<p
:class="[
navIdx === currentNavigationIndex
? 'text-zinc-100'
: 'text-zinc-400 group-hover:text-zinc-300',
'truncate transition text-sm font-display leading-6',
]"
>
{{ nav.label }}
</p>
</div>
<p class="truncate text-sm font-display leading-6 flex-1">
{{ nav.label }}
</p>
</div>
</NuxtLink>
</ul>

View file

@ -20,8 +20,10 @@
<div class="h-full flex flex-row gap-x-4 items-stretch">
<GameStatusButton
@install="() => installFlow()"
@play="() => play()"
@launch="() => launch()"
@queue="() => queue()"
@uninstall="() => uninstall()"
@kill="() => kill()"
:status="status"
/>
<a
@ -38,285 +40,222 @@
</div>
</div>
<TransitionRoot as="template" :show="installFlowOpen">
<Dialog class="relative z-50" @close="installFlowOpen = false">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity"
/>
</TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div
class="flex min-h-full items-start justify-center p-4 text-center sm:items-center sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<form
@submit.prevent="() => install()"
class="relative transform rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
>
<div class="px-4 pb-4 pt-5 space-y-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<DialogTitle
as="h3"
class="text-base font-semibold text-zinc-100"
>Install {{ game.mName }}?
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-zinc-400">
Drop will add {{ game.mName }} to the queue to be
downloaded. While downloading, Drop may use up a large
amount of resources, particularly network bandwidth and
CPU utilisation.
</p>
</div>
</div>
</div>
<div class="space-y-6">
<div v-if="versionOptions && versionOptions.length > 0">
<Listbox as="div" v-model="installVersionIndex">
<ListboxLabel
class="block text-sm/6 font-medium text-zinc-100"
>Version</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate"
>{{
versionOptions[installVersionIndex].versionName
}}
on
{{
versionOptions[installVersionIndex].platform
}}</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versionOptions"
:key="version.versionName"
:value="versionIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active
? 'bg-blue-600 text-white'
: 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ version.versionName }} on
{{ version.platform }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon
class="h-5 w-5"
aria-hidden="true"
/>
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
There are no supported versions to install. Please
contact your server admin or try again later.
</h3>
</div>
</div>
</div>
<div v-if="installDirs">
<Listbox as="div" v-model="installDir">
<ListboxLabel
class="block text-sm/6 font-medium text-zinc-100"
>Install to</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{
installDirs[installDir]
}}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active
? 'bg-blue-600 text-white'
: 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ dir }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon
class="h-5 w-5"
aria-hidden="true"
/>
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
</div>
<div
v-if="installError"
class="mt-1 rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ installError }}
</h3>
</div>
</div>
</div>
</div>
<div
class="rounded-b-lg bg-zinc-800 px-4 py-3 sm:flex sm:gap-x-2 sm:flex-row-reverse sm:px-6"
>
<LoadingButton
:disabled="
!(
versionOptions &&
versionOptions.length > 0 &&
!installDir
)
"
:loading="installLoading"
type="submit"
class="w-full sm:w-fit"
>
Install
</LoadingButton>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="installFlowOpen = false"
ref="cancelButtonRef"
>
Cancel
</button>
</div>
</form>
</TransitionChild>
<ModalTemplate v-model="installFlowOpen">
<template #default>
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:text-left">
<DialogTitle as="h3" class="text-base font-semibold text-zinc-100"
>Install {{ game.mName }}?
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-zinc-400">
Drop will add {{ game.mName }} to the queue to be downloaded.
While downloading, Drop may use up a large amount of resources,
particularly network bandwidth and CPU utilisation.
</p>
</div>
</div>
</div>
</Dialog>
</TransitionRoot>
<form class="space-y-6">
<div v-if="versionOptions && versionOptions.length > 0">
<Listbox as="div" v-model="installVersionIndex">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
>Version</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate"
>{{ versionOptions[installVersionIndex].versionName }}
on
{{ versionOptions[installVersionIndex].platform }}</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versionOptions"
:key="version.versionName"
:value="versionIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ version.versionName }} on
{{ version.platform }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
There are no supported versions to install. Please contact your
server admin or try again later.
</h3>
</div>
</div>
</div>
<div v-if="installDirs">
<Listbox as="div" v-model="installDir">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
>Install to</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{
installDirs[installDir]
}}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(dir, dirIdx) in installDirs"
:key="dir"
:value="dirIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ dir }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
<div class="text-zinc-400 text-sm mt-2">
Add more install directories in
<PageWidget to="/settings/downloads">
<WrenchIcon class="size-3" />
Settings
</PageWidget>
</div>
</Listbox>
</div>
</form>
<div v-if="installError" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ installError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
@click="() => install()"
:disabled="
!(versionOptions && versionOptions.length > 0 && !installDir)
"
:loading="installLoading"
type="submit"
class="w-full sm:w-fit"
>
Install
</LoadingButton>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="installFlowOpen = false"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
@ -331,7 +270,11 @@ import {
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import {
CheckIcon,
ChevronUpDownIcon,
WrenchIcon,
} from "@heroicons/vue/20/solid";
import { BuildingStorefrontIcon } from "@heroicons/vue/24/outline";
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { invoke } from "@tauri-apps/api/core";
@ -356,6 +299,8 @@ const versionOptions = ref<
const installDirs = ref<undefined | Array<string>>();
async function installFlow() {
installFlowOpen.value = true;
versionOptions.value = undefined;
installDirs.value = undefined;
try {
versionOptions.value = await invoke("fetch_game_verion_options", {
@ -389,9 +334,9 @@ async function install() {
}
}
async function play() {
async function launch() {
try {
await invoke("launch_game", { gameId: game.value.id });
await invoke("launch_game", { id: game.value.id });
} catch (e) {
createModal(
ModalType.Notification,
@ -409,4 +354,25 @@ async function play() {
async function queue() {
router.push("/queue");
}
async function uninstall() {
await invoke("uninstall_game", { gameId: game.value.id });
}
async function kill() {
try {
await invoke("kill_game", { gameId: game.value.id });
} catch (e) {
createModal(
ModalType.Notification,
{
title: `Couldn't stop "${game.value.mName}"`,
description: `Drop failed to stop "${game.value.mName}": ${e}`,
buttonText: "Close",
},
(e, c) => c()
);
console.error(e);
}
}
</script>

View file

@ -1,27 +1,32 @@
<template>
<div class="bg-zinc-950 p-4 min-h-full">
<div class="bg-zinc-950 p-4 min-h-full space-y-4">
<div 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">
<span class="font-semibold">{{ formatKilobytes(stats.speed) }}</span>
<span v-if="stats.time > 0" class="text-sm">{{ formatTime(stats.time) }} left</span>
</div>
<div class="absolute inset-0 h-full flex flex-row items-end justify-end">
<div v-for="bar in speedHistory" :style="{ height: `${bar / speedMax * 100}%` }"
class="w-[8px] bg-blue-600/40" />
</div>
</div>
<draggable v-model="queue.queue" @end="onEnd">
<template #item="{ element }: { element: (typeof queue.value.queue)[0] }">
<li
v-if="games[element.id]"
:key="element.id"
class="mb-4 bg-zinc-900 rounded-lg flex flex-row justify-between gap-x-6 py-5 px-4"
>
<li v-if="games[element.meta.id]" :key="element.meta.id"
class="mb-4 bg-zinc-900 rounded-lg flex flex-row justify-between gap-x-6 py-5 px-4">
<div class="w-full flex items-center max-w-md gap-x-4 relative">
<img
class="size-24 flex-none bg-zinc-800 object-cover rounded"
:src="games[element.id].cover"
alt=""
/>
<img class="size-24 flex-none bg-zinc-800 object-cover rounded" :src="games[element.meta.id].cover" alt="" />
<div class="min-w-0 flex-auto">
<p class="text-xl font-semibold text-zinc-100">
<NuxtLink :href="`/library/${element.id}`" class="">
<NuxtLink :href="`/library/${element.meta.id}`" class="">
<span class="absolute inset-x-0 -top-px bottom-0" />
{{ games[element.id].game.mName }}
{{ games[element.meta.id].game.mName }}
</NuxtLink>
</p>
<p class="mt-1 flex text-xs/5 text-gray-500">
{{ games[element.id].game.mShortDescription }}
{{ games[element.meta.id].game.mShortDescription }}
</p>
</div>
</div>
@ -30,31 +35,20 @@
<p class="text-md text-zinc-500 uppercase font-display font-bold">
{{ element.status }}
</p>
<div
v-if="element.progress"
class="mt-1 w-96 bg-zinc-800 rounded-lg overflow-hidden"
>
<div
class="h-2 bg-blue-600"
:style="{ width: `${element.progress * 100}%` }"
/>
<div v-if="element.progress" class="mt-1 w-96 bg-zinc-800 rounded-lg overflow-hidden">
<div class="h-2 bg-blue-600" :style="{ width: `${element.progress * 100}%` }" />
</div>
</div>
<button @click="() => cancelGame(element.id)" class="group">
<XMarkIcon
class="transition size-8 flex-none text-zinc-600 group-hover:text-zinc-300"
aria-hidden="true"
/>
<button @click="() => cancelGame(element.meta)" class="group">
<XMarkIcon class="transition size-8 flex-none text-zinc-600 group-hover:text-zinc-300"
aria-hidden="true" />
</button>
</div>
</li>
<p v-else>Loading...</p>
</template>
</draggable>
<div
class="text-zinc-600 uppercase font-semibold font-display w-full text-center"
v-if="queue.queue.length == 0"
>
<div class="text-zinc-600 uppercase font-semibold font-display w-full text-center" v-if="queue.queue.length == 0">
No items in the queue
</div>
</div>
@ -63,23 +57,68 @@
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core";
import type { Game, GameStatus } from "~/types";
import type { DownloadableMetadata, Game, GameStatus } from "~/types";
const windowWidth = ref(window.innerWidth);
window.addEventListener('resize', (event) => {
windowWidth.value = window.innerWidth;
})
const queue = useQueueState();
const current = computed(() => queue.value.queue.at(0));
const rest = computed(() => queue.value.queue.slice(1));
const stats = useStatsState();
const speedHistory = useState<Array<number>>(() => []);
const speedHistoryMax = computed(() => windowWidth.value / 8);
const speedMax = computed(() => speedHistory.value.reduce((a, b) => a > b ? a : b) * 1.3);
const previousGameId = ref<string | undefined>();
const games: Ref<{
[key: string]: { game: Game; status: Ref<GameStatus>; cover: string };
}> = ref({});
function resetHistoryGraph() {
speedHistory.value = [];
stats.value = { time: 0, speed: 0 };
}
function checkReset(v: QueueState) {
const currentGame = v.queue.at(0)?.meta.id;
// 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;
resetHistoryGraph();
return;
}
// If it's a different game now
if (currentGame != previousGameId.value
) {
previousGameId.value = currentGame;
resetHistoryGraph();
return;
}
}
watch(queue, (v) => {
loadGamesForQueue(v);
checkReset(v);
});
watch(stats, (v) => {
const newLength = speedHistory.value.push(v.speed);
if (newLength > speedHistoryMax.value) {
speedHistory.value.splice(0, 1);
}
checkReset(queue.value);
})
function loadGamesForQueue(v: typeof queue.value) {
for (const { id } of v.queue) {
for (const { meta: { id } } of v.queue) {
if (games.value[id]) return;
(async () => {
const gameData = await useGame(id);
@ -98,7 +137,35 @@ async function onEnd(event: { oldIndex: number; newIndex: number }) {
});
}
async function cancelGame(id: string) {
await invoke("cancel_game", { gameId: id });
async function cancelGame(meta: DownloadableMetadata) {
await invoke("cancel_game", { meta });
}
function formatKilobytes(bytes: number): string {
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let unitIndex = 0;
const scalar = 1000;
while (value >= scalar && unitIndex < units.length - 1) {
value /= scalar;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}/s`;
}
function formatTime(seconds: number): string {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m ${Math.round(seconds % 60)}s`
}
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
</script>

View file

@ -9,25 +9,18 @@
<nav class="flex flex-col" aria-label="Sidebar">
<ul role="list" class="-mx-2 space-y-1">
<li v-for="(item, itemIdx) in navigation" :key="item.prefix">
<NuxtLink
:href="item.route"
:class="[
<NuxtLink :href="item.route" :class="[
itemIdx === currentPageIndex
? 'bg-zinc-800/50 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/30 hover:text-zinc-200',
'transition group flex gap-x-3 rounded-md p-2 pr-12 text-sm font-semibold leading-6',
]">
<component :is="item.icon" :class="[
itemIdx === currentPageIndex
? 'bg-zinc-800/50 text-zinc-100'
: 'text-zinc-400 hover:bg-zinc-800/30 hover:text-zinc-200',
'transition group flex gap-x-3 rounded-md p-2 pr-12 text-sm font-semibold leading-6',
]"
>
<component
:is="item.icon"
:class="[
itemIdx === currentPageIndex
? 'text-zinc-100'
: 'text-zinc-400 group-hover:text-zinc-200',
'transition h-6 w-6 shrink-0',
]"
aria-hidden="true"
/>
? 'text-zinc-100'
: 'text-zinc-400 group-hover:text-zinc-200',
'transition h-6 w-6 shrink-0',
]" aria-hidden="true" />
{{ item.label }}
</NuxtLink>
</li>
@ -43,11 +36,14 @@
<script setup lang="ts">
import {
ArrowDownTrayIcon,
CubeIcon,
HomeIcon,
RectangleGroupIcon,
BugAntIcon,
} from "@heroicons/vue/16/solid";
import type { Component } from "vue";
import type { NavigationItem } from "~/types";
import { platform } from '@tauri-apps/plugin-os';
const navigation: Array<NavigationItem & { icon: Component }> = [
{
@ -68,7 +64,20 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
prefix: "/settings/downloads",
icon: ArrowDownTrayIcon,
},
{
label: "Debug Info",
route: "/settings/debug",
prefix: "/settings/debug",
icon: BugAntIcon,
},
];
const currentPlatform = platform();
switch (currentPlatform) {
case "linux":
navigation.splice(2, 0, { label: "Compatibility", route: "/settings/compatibility", prefix: "/settings/compatibility", icon: CubeIcon });
break;
}
const currentPageIndex = useCurrentNavigationIndex(navigation);
</script>

View file

@ -0,0 +1,3 @@
<template>
</template>

161
pages/settings/debug.vue Normal file
View file

@ -0,0 +1,161 @@
<template>
<div class="divide-y divide-zinc-700">
<div class="py-6">
<h2 class="text-base font-semibold font-display leading-7 text-zinc-100">
Debug Information
</h2>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Technical information about your Drop client installation, helpful for
debugging.
</p>
<div class="mt-10 space-y-8">
<div>
<div class="flex items-center gap-x-3">
<FingerPrintIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Client ID
</h3>
</div>
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
{{ clientId || "Not signed in" }}
</p>
</div>
<div>
<div class="flex items-center gap-x-3">
<ComputerDesktopIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Platform
</h3>
</div>
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
{{ platformInfo }}
</p>
</div>
<div>
<div class="flex items-center gap-x-3">
<ServerIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Server URL
</h3>
</div>
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
{{ baseUrl || "Not connected" }}
</p>
</div>
<div>
<div class="flex items-center gap-x-3">
<FolderIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Data Directory
</h3>
</div>
<p class="mt-2 text-sm text-zinc-400 font-mono ml-8">
{{ dataDir || "Unknown" }}
</p>
</div>
<div v-if="compatInfo">
<div class="flex items-center gap-x-3">
<CubeIcon class="h-5 w-5 text-zinc-400" />
<h3 class="text-sm font-medium leading-6 text-zinc-100">
Compatibility Settings
</h3>
</div>
<div class="mt-2 ml-8 space-y-1">
<p class="text-sm text-zinc-400 font-mono">
Enabled: {{ compatInfo.enabled ? "Yes" : "No" }}
</p>
<p class="text-sm text-zinc-400 font-mono">
Runner: {{ compatInfo.runner || "Not configured" }}
</p>
<p class="text-sm text-zinc-400 font-mono">
Prefix: {{ compatInfo.prefix || "Not configured" }}
</p>
</div>
</div>
<div class="pt-6 flex gap-x-4">
<button
@click="() => openDataDir()"
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<FolderIcon class="h-5 w-5" aria-hidden="true" />
Open Data Directory
</button>
<button
@click="() => openLogFile()"
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<DocumentTextIcon class="h-5 w-5" aria-hidden="true" />
Open Log File
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { platform, type } from "@tauri-apps/plugin-os";
import {
FingerPrintIcon,
TagIcon,
ComputerDesktopIcon,
ServerIcon,
FolderIcon,
CubeIcon,
DocumentTextIcon,
} from "@heroicons/vue/24/outline";
import { open } from "@tauri-apps/plugin-shell";
const clientId = ref<string | null>(null);
const platformInfo = ref("Loading...");
const baseUrl = ref<string | null>(null);
const dataDir = ref<string | null>(null);
const compatInfo = ref<{
enabled: boolean;
runner: string | null;
prefix: string | null;
} | null>(null);
const systemData = await invoke<{
clientId: string;
baseUrl: string;
dataDir: string;
}>("fetch_system_data");
console.log(systemData);
clientId.value = systemData.clientId;
baseUrl.value = systemData.baseUrl;
dataDir.value = systemData.dataDir;
const currentPlatform = await platform();
platformInfo.value = currentPlatform;
async function openDataDir() {
if (!dataDir.value) return;
try {
await open(dataDir.value);
} catch (error) {
console.error("Failed to open data dir:", error);
}
}
async function openLogFile() {
if (!dataDir.value) return;
try {
const logPath = `${dataDir.value}/drop.log`;
await open(logPath);
} catch (error) {
console.error("Failed to open log file:", error);
}
}
</script>

View file

@ -1,3 +1,60 @@
<template>
</template>
<div class="divide-y divide-zinc-700">
<div class="py-6">
<h2 class="text-base font-semibold font-display leading-7 text-zinc-100">General</h2>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Configure basic application settings
</p>
<div class="mt-10 space-y-8">
<div class="flex flex-row items-center justify-between">
<div>
<h3 class="text-sm font-medium leading-6 text-zinc-100">Start with system</h3>
<p class="mt-1 text-sm leading-6 text-zinc-400">
Drop will automatically start when you log into your computer
</p>
</div>
<Switch
v-model="autostartEnabled"
:class="[
autostartEnabled ? 'bg-blue-600' : 'bg-zinc-700',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out'
]"
>
<span
:class="[
autostartEnabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
]"
/>
</Switch>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Switch } from '@headlessui/vue'
import { invoke } from "@tauri-apps/api/core";
defineProps<{}>()
const autostartEnabled = ref<boolean>(false)
// Load initial state
invoke('get_autostart_enabled').then((enabled) => {
autostartEnabled.value = enabled as boolean
})
// Watch for changes and update autostart
watch(autostartEnabled, async (newValue: boolean) => {
try {
await invoke('toggle_autostart', { enabled: newValue })
} catch (error) {
console.error('Failed to toggle autostart:', error)
// Revert the toggle if it failed
autostartEnabled.value = !newValue
}
})
</script>

View file

@ -1,3 +1,7 @@
<template>
</template>
</template>
<script setup lang="ts">
</script>

140
src-tauri/Cargo.lock generated
View file

@ -261,6 +261,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.4.0"
@ -355,6 +366,12 @@ dependencies = [
"piper",
]
[[package]]
name = "boxcar"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f839cdf7e2d3198ac6ca003fd8ebc61715755f41c1cad15ff13df67531e00ed"
[[package]]
name = "brotli"
version = "7.0.0"
@ -864,7 +881,16 @@ version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
"dirs-sys",
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
@ -873,7 +899,18 @@ version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys",
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]]
@ -965,6 +1002,7 @@ dependencies = [
name = "drop-app"
version = "0.1.0"
dependencies = [
"boxcar",
"chrono",
"directories",
"hex",
@ -980,13 +1018,18 @@ dependencies = [
"serde",
"serde-binary",
"serde_json",
"serde_with",
"shared_child",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-os",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tokio",
"umu-wrapper-lib",
"url",
"urlencoding",
"uuid",
@ -1037,7 +1080,7 @@ dependencies = [
"rustc_version",
"toml 0.8.2",
"vswhom",
"winreg",
"winreg 0.52.0",
]
[[package]]
@ -1435,6 +1478,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
dependencies = [
"rustix",
"windows-targets 0.52.6",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@ -2853,6 +2906,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb6651f4be5e39563c4fe5cc8326349eb99a25d805a3493f791d5bfd0269e430"
dependencies = [
"log",
"serde",
"windows-sys 0.52.0",
]
[[package]]
name = "os_pipe"
version = "1.2.1"
@ -3793,9 +3857,9 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.11.0"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817"
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
dependencies = [
"base64 0.22.1",
"chrono",
@ -3811,9 +3875,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.11.0"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
dependencies = [
"darling",
"proc-macro2",
@ -4107,6 +4171,15 @@ dependencies = [
"syn 2.0.91",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
@ -4205,7 +4278,7 @@ checksum = "e545de0a2dfe296fa67db208266cd397c5a55ae782da77973ef4c4fac90e9f2c"
dependencies = [
"anyhow",
"bytes",
"dirs",
"dirs 5.0.1",
"dunce",
"embed_plist",
"futures-util",
@ -4255,7 +4328,7 @@ checksum = "7bd2a4bcfaf5fb9f4be72520eefcb61ae565038f8ccba2a497d8c28f463b8c01"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 5.0.1",
"glob",
"heck 0.5.0",
"json-patch",
@ -4327,6 +4400,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9c13f843e5e5df3eed270fc42b02923cc1a6b5c7e56b0f3ac1d858ab2c8b5fb"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.9",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.2.0"
@ -4388,6 +4475,24 @@ dependencies = [
"uuid",
]
[[package]]
name = "tauri-plugin-os"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dda2d571a9baf0664c1f2088db227e3072f9028602fafa885deade7547c3b738"
dependencies = [
"gethostname",
"log",
"os_info",
"serde",
"serde_json",
"serialize-to-javascript",
"sys-locale",
"tauri",
"tauri-plugin",
"thiserror 2.0.9",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.2.0"
@ -4813,7 +4918,7 @@ checksum = "d48a05076dd272615d03033bf04f480199f7d1b66a8ac64d75c625fc4a70c06b"
dependencies = [
"core-graphics",
"crossbeam-channel",
"dirs",
"dirs 5.0.1",
"libappindicator",
"muda",
"objc2",
@ -4870,6 +4975,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "umu-wrapper-lib"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa36636bef667cee9eb4f497c24279182b8b9f098fd04b0b8c5d2ebc4e451f1"
[[package]]
name = "unic-char-property"
version = "0.9.0"
@ -5713,6 +5824,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"

View file

@ -1,8 +1,8 @@
[package]
name = "drop-app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -26,7 +26,6 @@ tauri-build = { version = "2.0.0", features = [] }
[dependencies]
tauri-plugin-shell = "2.0.0"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
serde-binary = "0.5.0"
rayon = "1.10.0"
@ -41,12 +40,16 @@ 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"
[dependencies.tauri]
version = "2.1.1"
features = [
"tray-icon"
]
features = ["tray-icon"]
[dependencies.tokio]
@ -81,6 +84,10 @@ features = [] # You can also use "yaml_enc" or "bin_enc"
version = "0.12"
features = ["json", "blocking"]
[dependencies.serde]
version = "1"
features = ["derive", "rc"]
[profile.release]
lto = true
codegen-units = 1

View file

@ -13,6 +13,7 @@
"core:window:allow-maximize",
"core:window:allow-close",
"deep-link:default",
"dialog:default"
"dialog:default",
"os:default"
]
}

View file

@ -1,8 +1,4 @@
use std::{
env,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
use std::{env, sync::Mutex};
use chrono::Utc;
use log::{info, warn};
@ -13,7 +9,7 @@ use url::Url;
use crate::{
db::{DatabaseAuth, DatabaseImpls},
remote::RemoteAccessError,
remote::{DropServerError, RemoteAccessError},
AppState, AppStatus, User, DB,
};
@ -78,7 +74,13 @@ pub fn fetch_user() -> Result<User, RemoteAccessError> {
.send()?;
if response.status() != 200 {
info!("Could not fetch user: {}", response.text().unwrap());
let data = response.json::<DropServerError>()?;
info!("Could not fetch user: {}", data.status_message);
if data.status_message == "Nonce expired" {
return Err(RemoteAccessError::OutOfSync);
}
return Err(RemoteAccessError::InvalidCodeError(0));
}
@ -91,7 +93,9 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc
let path_chunks: Vec<&str> = path.split("/").collect();
if path_chunks.len() != 3 {
app.emit("auth/failed", ()).unwrap();
return Err(RemoteAccessError::InvalidResponse);
return Err(RemoteAccessError::HandshakeFailed(
"failed to parse token".to_string(),
));
}
let base_url = {
@ -133,6 +137,12 @@ fn recieve_handshake_logic(app: &AppHandle, path: String) -> Result<(), RemoteAc
Ok(())
}
#[tauri::command]
pub fn manual_recieve_handshake(app: AppHandle, token: String) -> Result<(), String> {
recieve_handshake(app, format!("handshake/{}", token));
Ok(())
}
pub fn recieve_handshake(app: AppHandle, path: String) {
// Tell the app we're processing
app.emit("auth/processing", ()).unwrap();
@ -147,7 +157,7 @@ pub fn recieve_handshake(app: AppHandle, path: String) {
app.emit("auth/finished", ()).unwrap();
}
async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
let base_url = {
let db_lock = DB.borrow_data().unwrap();
Url::parse(&db_lock.base_url.clone())?
@ -159,14 +169,17 @@ async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
platform: env::consts::OS.to_string(),
};
let client = reqwest::Client::new();
let response = client.post(endpoint.to_string()).json(&body).send().await?;
let client = reqwest::blocking::Client::new();
let response = client.post(endpoint.to_string()).json(&body).send()?;
if response.status() != 200 {
return Err(RemoteAccessError::InvalidRedirect);
let data = response.json::<DropServerError>()?;
info!("Could not start handshake: {}", data.status_message);
return Err(RemoteAccessError::HandshakeFailed(data.status_message));
}
let redir_url = response.text().await?;
let redir_url = response.text()?;
let complete_redir_url = base_url.join(&redir_url)?;
info!("opening web browser to continue authentication");
@ -176,8 +189,8 @@ async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
}
#[tauri::command]
pub async fn auth_initiate<'a>() -> Result<(), String> {
let result = auth_initiate_wrapper().await;
pub fn auth_initiate<'a>() -> Result<(), String> {
let result = auth_initiate_wrapper();
if result.is_err() {
return Err(result.err().unwrap().to_string());
}
@ -199,8 +212,10 @@ pub fn retry_connect(state: tauri::State<'_, Mutex<AppState>>) -> Result<(), ()>
pub fn setup() -> Result<(AppStatus, Option<User>), ()> {
let data = DB.borrow_data().unwrap();
let auth = data.auth.clone();
drop(data);
if data.auth.is_some() {
if auth.is_some() {
let user_result = fetch_user();
if user_result.is_err() {
let error = user_result.err().unwrap();
@ -215,7 +230,31 @@ pub fn setup() -> Result<(AppStatus, Option<User>), ()> {
return Ok((AppStatus::SignedIn, Some(user_result.unwrap())));
}
drop(data);
Ok((AppStatus::SignedOut, None))
}
#[tauri::command]
pub fn sign_out(app: AppHandle) -> Result<(), String> {
info!("Signing out user");
// Clear auth from database
{
let mut handle = DB.borrow_data_mut().unwrap();
handle.auth = None;
drop(handle);
DB.save().unwrap();
}
// Update app state
{
let app_state = app.state::<Mutex<AppState>>();
let mut app_state_handle = app_state.lock().unwrap();
app_state_handle.status = AppStatus::SignedOut;
app_state_handle.user = None;
}
// Emit event for frontend
app.emit("auth/signedout", ()).unwrap();
Ok(())
}

View file

@ -0,0 +1,69 @@
use log::info;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
use crate::DB;
#[tauri::command]
pub async fn toggle_autostart(app: AppHandle, enabled: bool) -> Result<(), String> {
let manager = app.autolaunch();
if enabled {
manager.enable().map_err(|e| e.to_string())?;
info!("Enabled autostart");
} else {
manager.disable().map_err(|e| e.to_string())?;
info!("Disabled autostart");
}
// Store the state in DB
let mut db_handle = DB.borrow_data_mut().map_err(|e| e.to_string())?;
db_handle.settings.autostart = enabled;
drop(db_handle);
DB.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_autostart_enabled(app: AppHandle) -> Result<bool, String> {
// First check DB state
let db_handle = DB.borrow_data().map_err(|e| e.to_string())?;
let db_state = db_handle.settings.autostart;
drop(db_handle);
// Get actual system state
let manager = app.autolaunch();
let system_state = manager.is_enabled().map_err(|e| e.to_string())?;
// If they don't match, sync to DB state
if db_state != system_state {
if db_state {
manager.enable().map_err(|e| e.to_string())?;
} else {
manager.disable().map_err(|e| e.to_string())?;
}
}
Ok(db_state)
}
// New function to sync state on startup
pub fn sync_autostart_on_startup(app: &AppHandle) -> Result<(), String> {
let db_handle = DB.borrow_data().map_err(|e| e.to_string())?;
let should_be_enabled = db_handle.settings.autostart;
drop(db_handle);
let manager = app.autolaunch();
let current_state = manager.is_enabled().map_err(|e| e.to_string())?;
if current_state != should_be_enabled {
if should_be_enabled {
manager.enable().map_err(|e| e.to_string())?;
info!("Synced autostart: enabled");
} else {
manager.disable().map_err(|e| e.to_string())?;
info!("Synced autostart: disabled");
}
}
Ok(())
}

View file

@ -1,17 +1,14 @@
use std::sync::Mutex;
use log::info;
use tauri::AppHandle;
use crate::AppState;
#[tauri::command]
pub fn quit(app: tauri::AppHandle) {
cleanup_and_exit(&app);
}
pub fn cleanup_and_exit(app: &AppHandle, ) {
pub fn cleanup_and_exit(app: &AppHandle) {
info!("exiting drop application...");
app.exit(0);

View file

@ -2,19 +2,21 @@ use std::{
collections::HashMap,
fs::{self, create_dir_all},
path::{Path, PathBuf},
sync::{LazyLock, Mutex},
sync::{Arc, LazyLock, Mutex, RwLockWriteGuard}, time::{Instant, SystemTime, UNIX_EPOCH},
};
use chrono::Utc;
use directories::BaseDirs;
use log::debug;
use rustbreak::{DeSerError, DeSerializer, PathDatabase};
use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_with::serde_as;
use tauri::AppHandle;
use url::Url;
use crate::{process::process_manager::Platform, DB};
use crate::{download_manager::downloadable_metadata::DownloadableMetadata, games::{library::push_game_update, state::GameStatusManager}, process::process_manager::Platform, DB};
#[derive(serde::Serialize, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseAuth {
pub private: String,
pub cert: String,
@ -24,7 +26,7 @@ pub struct DatabaseAuth {
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize)]
#[serde(tag = "type")]
pub enum GameStatus {
pub enum GameDownloadStatus {
Remote {},
SetupRequired {
version_name: String,
@ -38,13 +40,14 @@ pub enum GameStatus {
// Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize)]
pub enum GameTransientStatus {
pub enum ApplicationTransientStatus {
Downloading { version_name: String },
Uninstalling {},
Updating { version_name: String },
Running {},
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GameVersion {
pub version_index: usize,
@ -54,24 +57,43 @@ pub struct GameVersion {
pub platform: Platform,
}
#[serde_as]
#[derive(Serialize, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DatabaseGames {
pub struct DatabaseApplications {
pub install_dirs: Vec<String>,
// Guaranteed to exist if the game also exists in the app state map
pub statuses: HashMap<String, GameStatus>,
pub versions: HashMap<String, HashMap<String, GameVersion>>,
pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,
#[serde(skip)]
pub transient_statuses: HashMap<String, GameTransientStatus>,
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>,
}
#[derive(Serialize, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(Serialize, Deserialize, Clone)]
pub struct Settings {
pub autostart: bool,
// ... other settings ...
}
impl Default for Settings {
fn default() -> Self {
Self {
autostart: false,
// ... other settings defaults ...
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub base_url: String,
pub games: DatabaseGames,
pub applications: DatabaseApplications,
pub prev_database: Option<PathBuf>
}
pub static DATA_ROOT_DIR: LazyLock<Mutex<PathBuf>> =
LazyLock::new(|| Mutex::new(BaseDirs::new().unwrap().data_dir().join("drop")));
@ -112,21 +134,27 @@ impl DatabaseImpls for DatabaseInterface {
debug!("Creating logs directory");
create_dir_all(logs_root_dir.clone()).unwrap();
#[allow(clippy::let_and_return)]
let exists = fs::exists(db_path.clone()).unwrap();
match exists {
true => PathDatabase::load_from_path(db_path).expect("Database loading failed"),
true =>
match PathDatabase::load_from_path(db_path.clone()) {
Ok(db) => db,
Err(e) => handle_invalid_database(e, db_path, games_base_dir),
},
false => {
let default = Database {
settings: Settings::default(),
auth: None,
base_url: "".to_string(),
games: DatabaseGames {
applications: DatabaseApplications {
install_dirs: vec![games_base_dir.to_str().unwrap().to_string()],
statuses: HashMap::new(),
game_statuses: HashMap::new(),
transient_statuses: HashMap::new(),
versions: HashMap::new(),
game_versions: HashMap::new(),
installed_game_version: HashMap::new(),
},
prev_database: None,
};
debug!(
"Creating database at path {}",
@ -172,10 +200,10 @@ pub fn add_download_dir(new_dir: String) -> Result<(), String> {
// Add it to the dictionary
let mut lock = DB.borrow_data_mut().unwrap();
if lock.games.install_dirs.contains(&new_dir) {
if lock.applications.install_dirs.contains(&new_dir) {
return Err("Download directory already used".to_string());
}
lock.games.install_dirs.push(new_dir);
lock.applications.install_dirs.push(new_dir);
drop(lock);
DB.save().unwrap();
@ -185,7 +213,7 @@ pub fn add_download_dir(new_dir: String) -> Result<(), String> {
#[tauri::command]
pub fn delete_download_dir(index: usize) -> Result<(), String> {
let mut lock = DB.borrow_data_mut().unwrap();
lock.games.install_dirs.remove(index);
lock.applications.install_dirs.remove(index);
drop(lock);
DB.save().unwrap();
@ -197,8 +225,54 @@ pub fn delete_download_dir(index: usize) -> Result<(), String> {
#[tauri::command]
pub fn fetch_download_dir_stats() -> Result<Vec<String>, String> {
let lock = DB.borrow_data().unwrap();
let directories = lock.games.install_dirs.clone();
let directories = lock.applications.install_dirs.clone();
drop(lock);
Ok(directories)
}
pub fn set_game_status<F: FnOnce(&mut RwLockWriteGuard<'_, Database>, &DownloadableMetadata)>(
app_handle: &AppHandle,
meta: DownloadableMetadata,
setter: F,
) {
let mut db_handle = DB.borrow_data_mut().unwrap();
setter(&mut db_handle, &meta);
drop(db_handle);
DB.save().unwrap();
let status = GameStatusManager::fetch_state(&meta.id);
push_game_update(app_handle, &meta, status);
}
// TODO: Make the error relelvant rather than just assume that it's a Deserialize error
fn handle_invalid_database(_e: RustbreakError, db_path: PathBuf, games_base_dir: PathBuf) -> rustbreak::Database<Database, rustbreak::backend::PathBackend, DropDatabaseSerializer> {
let new_path = {
let time = Utc::now().timestamp();
let mut base = db_path.clone().into_os_string();
base.push(".");
base.push(time.to_string());
base
};
fs::copy(&db_path, &new_path).unwrap();
let db = Database {
auth: None,
base_url: "".to_string(),
applications: DatabaseApplications {
install_dirs: vec![games_base_dir.to_str().unwrap().to_string()],
game_statuses: HashMap::new(),
transient_statuses: HashMap::new(),
game_versions: HashMap::new(),
installed_game_version: HashMap::new(),
},
prev_database: Some(new_path.into()),
settings: Settings { autostart: false },
};
PathDatabase::create_at_path(db_path, db)
.expect("Database could not be created")
}

23
src-tauri/src/debug.rs Normal file
View file

@ -0,0 +1,23 @@
use crate::{DATA_ROOT_DIR, DB};
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SystemData {
client_id: String,
base_url: String,
data_dir: String,
}
#[tauri::command]
pub fn fetch_system_data() -> Result<SystemData, String> {
let db_handle = DB.borrow_data().map_err(|e| e.to_string())?;
let system_data = SystemData {
client_id: db_handle.auth.as_ref().unwrap().client_id.clone(),
base_url: db_handle.base_url.clone(),
data_dir: DATA_ROOT_DIR.lock().unwrap().to_string_lossy().to_string(),
};
drop(db_handle);
Ok(system_data)
}

View file

@ -0,0 +1,41 @@
use std::{fmt::{Display, Formatter}, io};
use crate::remote::RemoteAccessError;
// TODO: Rename / separate from downloads
#[derive(Debug, Clone)]
pub enum ApplicationDownloadError {
Communication(RemoteAccessError),
Checksum,
Setup(SetupError),
Lock,
IoError(io::ErrorKind),
DownloadError,
}
impl Display for ApplicationDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ApplicationDownloadError::Communication(error) => write!(f, "{}", error),
ApplicationDownloadError::Setup(error) => write!(f, "An error occurred while setting up the download: {}", error),
ApplicationDownloadError::Lock => write!(f, "Failed to acquire lock. Something has gone very wrong internally. Please restart the application"),
ApplicationDownloadError::Checksum => write!(f, "Checksum failed to validate for download"),
ApplicationDownloadError::IoError(error) => write!(f, "{}", error),
ApplicationDownloadError::DownloadError => write!(f, "Download failed. See Download Manager status for specific error"),
}
}
}
#[derive(Debug, Clone)]
pub enum SetupError {
Context,
}
impl Display for SetupError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SetupError::Context => write!(f, "Failed to generate contexts for download"),
}
}
}

View file

@ -12,47 +12,57 @@ use std::{
use log::info;
use serde::Serialize;
use super::{
download_agent::{GameDownloadAgent, GameDownloadError},
download_manager_builder::CurrentProgressObject,
progress_object::ProgressObject,
queue::Queue,
};
use super::{application_download_error::ApplicationDownloadError, download_manager_builder::{CurrentProgressObject, DownloadAgent}, downloadable_metadata::DownloadableMetadata, queue::Queue};
pub enum DownloadManagerSignal {
/// Resumes (or starts) the DownloadManager
Go,
/// Pauses the DownloadManager
Stop,
/// Called when a GameDownloadAgent has fully completed a download.
Completed(String),
/// Generates and appends a GameDownloadAgent
/// Called when a DownloadAgent has fully completed a download.
Completed(DownloadableMetadata),
/// Generates and appends a DownloadAgent
/// to the registry and queue
Queue(String, String, usize),
Queue(DownloadAgent),
/// Tells the Manager to stop the current
/// download, sync everything to disk, and
/// then exit
Finish,
/// Stops (but doesn't remove) current download
Cancel,
/// Removes a given game
Remove(String),
/// Stops, removes, and tells a download to cleanup
Cancel(DownloadableMetadata),
/// Removes a given application
Remove(DownloadableMetadata),
/// Any error which occurs in the agent
Error(GameDownloadError),
Error(ApplicationDownloadError),
/// Pushes UI update
Update,
UpdateUIQueue,
UpdateUIStats(usize, usize), //kb/s and seconds
/// Uninstall download
/// Takes download ID
Uninstall(DownloadableMetadata),
}
#[derive(Debug, Clone)]
pub enum DownloadManagerStatus {
Downloading,
Paused,
Empty,
Error(GameDownloadError),
Error(ApplicationDownloadError),
Finished,
}
#[derive(Serialize, Clone)]
pub enum GameDownloadStatus {
impl Serialize for DownloadManagerStatus {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&format!["{:?}", self])
}
}
#[derive(Serialize, Clone, Debug)]
pub enum DownloadStatus {
Queued,
Downloading,
Error,
@ -74,27 +84,6 @@ pub struct DownloadManager {
progress: CurrentProgressObject,
command_sender: Sender<DownloadManagerSignal>,
}
pub struct GameDownloadAgentQueueStandin {
pub id: String,
pub status: Mutex<GameDownloadStatus>,
pub progress: Arc<ProgressObject>,
}
impl From<Arc<GameDownloadAgent>> for GameDownloadAgentQueueStandin {
fn from(value: Arc<GameDownloadAgent>) -> Self {
Self {
id: value.id.clone(),
status: Mutex::from(GameDownloadStatus::Queued),
progress: value.progress.clone(),
}
}
}
impl Debug for GameDownloadAgentQueueStandin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GameDownloadAgentQueueStandin")
.field("id", &self.id)
.finish()
}
}
#[allow(dead_code)]
impl DownloadManager {
@ -112,42 +101,36 @@ impl DownloadManager {
}
}
pub fn queue_game(
pub fn queue_download(
&self,
id: String,
version: String,
target_download_dir: usize,
download: DownloadAgent
) -> Result<(), SendError<DownloadManagerSignal>> {
info!("Adding game id {}", id);
self.command_sender.send(DownloadManagerSignal::Queue(
id,
version,
target_download_dir,
))?;
info!("Adding download id {:?}", download.metadata());
self.command_sender.send(DownloadManagerSignal::Queue(download))?;
self.command_sender.send(DownloadManagerSignal::Go)
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>> {
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
self.download_queue.edit()
}
pub fn read_queue(&self) -> VecDeque<Arc<GameDownloadAgentQueueStandin>> {
pub fn read_queue(&self) -> VecDeque<DownloadableMetadata> {
self.download_queue.read()
}
pub fn get_current_game_download_progress(&self) -> Option<f64> {
pub fn get_current_download_progress(&self) -> Option<f64> {
let progress_object = (*self.progress.lock().unwrap()).clone()?;
Some(progress_object.get_progress())
}
pub fn rearrange_string(&self, id: String, new_index: usize) {
pub fn rearrange_string(&self, meta: &DownloadableMetadata, new_index: usize) {
let mut queue = self.edit();
let current_index = get_index_from_id(&mut queue, id).unwrap();
let current_index = get_index_from_id(&mut queue, meta).unwrap();
let to_move = queue.remove(current_index).unwrap();
queue.insert(new_index, to_move);
self.command_sender
.send(DownloadManagerSignal::Update)
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
pub fn cancel(&self, game_id: String) {
pub fn cancel(&self, meta: DownloadableMetadata) {
self.command_sender
.send(DownloadManagerSignal::Remove(game_id))
.send(DownloadManagerSignal::Cancel(meta))
.unwrap();
}
pub fn rearrange(&self, current_index: usize, new_index: usize) {
@ -158,7 +141,7 @@ impl DownloadManager {
let needs_pause = current_index == 0 || new_index == 0;
if needs_pause {
self.command_sender
.send(DownloadManagerSignal::Cancel)
.send(DownloadManagerSignal::Stop)
.unwrap();
}
@ -174,7 +157,7 @@ impl DownloadManager {
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
}
self.command_sender
.send(DownloadManagerSignal::Update)
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
pub fn pause_downloads(&self) {
@ -191,15 +174,23 @@ impl DownloadManager {
.unwrap();
self.terminator.join()
}
pub fn uninstall_application(&self, meta: DownloadableMetadata) {
self.command_sender
.send(DownloadManagerSignal::Uninstall(meta))
.unwrap();
}
pub fn get_sender(&self) -> Sender<DownloadManagerSignal> {
self.command_sender.clone()
}
}
/// Takes in the locked value from .edit() and attempts to
/// get the index of whatever game_id is passed in
/// get the index of whatever id is passed in
fn get_index_from_id(
queue: &mut MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>>,
id: String,
queue: &mut MutexGuard<'_, VecDeque<DownloadableMetadata>>,
meta: &DownloadableMetadata,
) -> Option<usize> {
queue
.iter()
.position(|download_agent| download_agent.id == id)
.position(|download_agent| download_agent == meta)
}

View file

@ -0,0 +1,343 @@
use std::{
collections::HashMap,
fs::remove_dir_all,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex, RwLockWriteGuard,
},
thread::{spawn, JoinHandle},
};
use log::{error, info};
use tauri::{AppHandle, Emitter};
use crate::games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent};
use super::{application_download_error::ApplicationDownloadError, download_manager::{DownloadManager, DownloadManagerSignal, DownloadManagerStatus}, download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag}, downloadable::Downloadable, downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject, queue::Queue};
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 a id-based reference system. The actual Agents are stored in the
download_agent_registry HashMap, as ordering is no issue here. This is why
appending or removing from the download_queue must be done via signals.
Behold, my madness - quexeky
*/
pub struct DownloadManagerBuilder {
download_agent_registry: HashMap<DownloadableMetadata, DownloadAgent>,
download_queue: Queue,
command_receiver: Receiver<DownloadManagerSignal>,
sender: Sender<DownloadManagerSignal>,
progress: CurrentProgressObject,
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>,
}
impl DownloadManagerBuilder {
pub fn build(app_handle: AppHandle) -> DownloadManager {
let queue = Queue::new();
let (command_sender, command_receiver) = channel();
let active_progress = Arc::new(Mutex::new(None));
let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty));
let manager = Self {
download_agent_registry: HashMap::new(),
download_queue: queue.clone(),
command_receiver,
status: status.clone(),
sender: command_sender.clone(),
progress: active_progress.clone(),
app_handle,
current_download_agent: None,
current_download_thread: Mutex::new(None),
active_control_flag: None,
};
let terminator = spawn(|| manager.manage_queue());
DownloadManager::new(terminator, queue, active_progress, command_sender)
}
fn set_status(&self, status: DownloadManagerStatus) {
*self.status.lock().unwrap() = status;
}
fn remove_and_cleanup_front_download(&mut self, meta: &DownloadableMetadata) -> DownloadAgent {
self.download_queue.pop_front();
let download_agent = self.download_agent_registry.remove(meta).unwrap();
self.cleanup_current_download();
download_agent
}
// CAREFUL WITH THIS FUNCTION
// Make sure the download thread is terminated
fn cleanup_current_download(&mut self) {
self.active_control_flag = None;
*self.progress.lock().unwrap() = None;
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = None;
drop(download_thread_lock);
}
fn stop_and_wait_current_download(&self) {
self.set_status(DownloadManagerStatus::Paused);
if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop);
}
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
}
fn manage_queue(mut self) -> Result<(), ()> {
loop {
let signal = match self.command_receiver.recv() {
Ok(signal) => signal,
Err(_) => return Err(()),
};
match signal {
DownloadManagerSignal::Go => {
self.manage_go_signal();
}
DownloadManagerSignal::Stop => {
self.manage_stop_signal();
}
DownloadManagerSignal::Completed(meta) => {
self.manage_completed_signal(meta);
}
DownloadManagerSignal::Queue(download_agent) => {
self.manage_queue_signal(download_agent);
}
DownloadManagerSignal::Error(e) => {
self.manage_error_signal(e);
}
DownloadManagerSignal::UpdateUIQueue => {
self.push_ui_queue_update();
}
DownloadManagerSignal::UpdateUIStats(kbs, time) => {
self.push_ui_stats_update(kbs, time);
}
DownloadManagerSignal::Finish => {
self.stop_and_wait_current_download();
return Ok(());
}
DownloadManagerSignal::Cancel(meta) => {
self.manage_cancel_signal(&meta);
}
_ => {}
};
}
}
fn manage_queue_signal(&mut self, download_agent: DownloadAgent) {
info!("Got signal Queue");
let meta = download_agent.metadata();
info!("Meta: {:?}", meta);
if self.download_queue.exists(meta.clone()) {
info!("Download with same ID already exists");
return;
}
download_agent.on_initialised(&self.app_handle);
self.download_queue.append(meta.clone());
self.download_agent_registry.insert(meta, download_agent);
self.sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap();
}
fn manage_go_signal(&mut self) {
info!("Got signal Go");
if self.download_agent_registry.is_empty() {
info!("Download agent registry: {:?}", self.download_agent_registry.len());
return;
}
if self.current_download_agent.is_some() {
info!("Current download agent: {:?}", self.current_download_agent.as_ref().unwrap().metadata());
return;
}
info!("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 download_agent = self
.download_agent_registry
.get(&agent_data)
.unwrap()
.clone();
self.active_control_flag = Some(download_agent.control_flag());
self.current_download_agent = Some(download_agent.clone());
let sender = self.sender.clone();
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
let app_handle = self.app_handle.clone();
*download_thread_lock = Some(spawn(move || {
match download_agent.download(&app_handle) {
// Ok(true) is for completed and exited properly
Ok(true) => {
download_agent.on_complete(&app_handle);
sender.send(DownloadManagerSignal::Completed(download_agent.metadata())).unwrap();
},
// Ok(false) is for incomplete but exited properly
Ok(false) => {
download_agent.on_incomplete(&app_handle);
},
Err(e) => {
download_agent.on_error(&app_handle, e.clone());
sender.send(DownloadManagerSignal::Error(e)).unwrap();
},
}
sender.send(DownloadManagerSignal::UpdateUIQueue).unwrap();
}));
self.set_status(DownloadManagerStatus::Downloading);
let active_control_flag = self.active_control_flag.clone().unwrap();
active_control_flag.set(DownloadThreadControlFlag::Go);
}
fn manage_stop_signal(&mut self) {
info!("Got signal Stop");
if let Some(active_control_flag) = self.active_control_flag.clone() {
self.set_status(DownloadManagerStatus::Paused);
active_control_flag.set(DownloadThreadControlFlag::Stop);
}
}
fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
info!("Got signal Completed");
if let Some(interface) = &self.current_download_agent {
if interface.metadata() == meta {
info!("Popping consumed data");
self.remove_and_cleanup_front_download(&meta);
}
}
self.push_ui_queue_update();
self.sender.send(DownloadManagerSignal::Go).unwrap();
}
fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
info!("Got signal Error");
let current_agent = self.current_download_agent.clone().unwrap();
current_agent.on_error(&self.app_handle, error.clone());
self.stop_and_wait_current_download();
self.remove_and_cleanup_front_download(&current_agent.metadata());
self.set_status(DownloadManagerStatus::Error(error));
}
fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) {
info!("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();
self.download_queue.pop_front();
self.cleanup_current_download();
info!("Current donwload 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) {
info!("Object exists in registry");
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);
info!("Removed {:?} from queue {:?}", removed.and_then(|x| Some(x.metadata())), self.download_queue.read());
}
}
}
}
else {
if let Some(download_agent) = self.download_agent_registry.get(meta) {
info!("Object exists in registry");
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);
info!("Removed {:?} from queue {:?}", removed.and_then(|x| Some(x.metadata())), self.download_queue.read());
}
}
}
self.push_ui_queue_update();
}
fn push_ui_stats_update(&self, kbs: usize, time: usize) {
let event_data = StatsUpdateEvent { speed: kbs, time };
self.app_handle.emit("update_stats", event_data).unwrap();
}
fn push_ui_queue_update(&self) {
let queue = &self.download_queue.read();
let queue_objs = queue
.iter()
.map(|(key)| {
let val = self.download_agent_registry.get(key).unwrap();
QueueUpdateEventQueueData {
meta: DownloadableMetadata::clone(&key),
status: val.status(),
progress: val.progress().get_progress()
}})
.collect();
let event_data = QueueUpdateEvent {
queue: queue_objs,
};
self.app_handle.emit("update_queue", event_data).unwrap();
}
}

View file

@ -0,0 +1,20 @@
use std::{fmt::{self, Debug}, sync::{mpsc::Sender, Arc}};
use tauri::AppHandle;
use super::{
application_download_error::ApplicationDownloadError, download_manager::{DownloadManagerSignal, DownloadStatus}, download_thread_control_flag::DownloadThreadControl, downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject
};
pub trait Downloadable: Send + Sync {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError>;
fn progress(&self) -> Arc<ProgressObject>;
fn control_flag(&self) -> DownloadThreadControl;
fn status(&self) -> DownloadStatus;
fn metadata(&self) -> DownloadableMetadata;
fn on_initialised(&self, app_handle: &AppHandle);
fn on_error(&self, app_handle: &AppHandle, error: ApplicationDownloadError);
fn on_complete(&self, app_handle: &AppHandle);
fn on_incomplete(&self, app_handle: &AppHandle);
fn on_cancelled(&self, app_handle: &AppHandle);
}

View file

@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone, Copy)]
pub enum DownloadType {
Game,
Tool,
DLC,
Mod
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DownloadableMetadata {
pub id: String,
pub version: Option<String>,
pub download_type: DownloadType
}
impl DownloadableMetadata {
pub fn new(id: String, version: Option<String>, download_type: DownloadType) -> Self {
Self {
id,
version,
download_type
}
}
}

View file

@ -0,0 +1,7 @@
use std::sync::Arc;
use super::{download_manager_builder::DownloadAgent, downloadable_metadata::DownloadableMetadata};
pub fn generate_downloadable(meta: DownloadableMetadata) -> DownloadAgent {
todo!()
}

View file

@ -0,0 +1,9 @@
pub mod download_manager;
pub mod download_manager_builder;
pub mod progress_object;
pub mod queue;
pub mod download_thread_control_flag;
pub mod downloadable;
pub mod application_download_error;
pub mod downloadable_metadata;
pub mod generate_downloadable;

View file

@ -2,7 +2,7 @@ use std::{
sync::{
atomic::{AtomicUsize, Ordering},
mpsc::Sender,
Arc, Mutex,
Arc, Mutex, RwLock,
},
time::Instant,
};
@ -19,7 +19,9 @@ pub struct ProgressObject {
sender: Sender<DownloadManagerSignal>,
points_towards_update: Arc<AtomicUsize>,
points_to_push_update: Arc<Mutex<usize>>,
points_to_push_update: Arc<AtomicUsize>,
last_update: Arc<RwLock<Instant>>,
amount_last_update: Arc<AtomicUsize>,
}
pub struct ProgressHandle {
@ -58,7 +60,9 @@ impl ProgressObject {
sender,
points_towards_update: Arc::new(AtomicUsize::new(0)),
points_to_push_update: Arc::new(Mutex::new(points_to_push_update)),
points_to_push_update: Arc::new(AtomicUsize::new(points_to_push_update)),
last_update: Arc::new(RwLock::new(Instant::now())),
amount_last_update: Arc::new(AtomicUsize::new(0)),
}
}
@ -67,16 +71,44 @@ impl ProgressObject {
.points_towards_update
.fetch_add(amount_added, Ordering::Relaxed);
let to_update_handle = self.points_to_push_update.lock().unwrap();
let to_update = *to_update_handle;
drop(to_update_handle);
let to_update = self.points_to_push_update.fetch_add(0, Ordering::Relaxed);
if current_amount < to_update {
return;
if current_amount >= to_update {
self.points_towards_update
.fetch_sub(to_update, Ordering::Relaxed);
self.sender
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
let last_update = self.last_update.read().unwrap();
let last_update_difference = Instant::now().duration_since(*last_update).as_millis();
if last_update_difference > 1000 {
// push update
drop(last_update);
let mut last_update = self.last_update.write().unwrap();
*last_update = Instant::now();
drop(last_update);
let current_amount = self.sum();
let max = self.get_max();
let amount_at_last_update = self.amount_last_update.fetch_add(0, Ordering::Relaxed);
self.amount_last_update
.store(current_amount, Ordering::Relaxed);
let amount_since_last_update = current_amount - amount_at_last_update;
let kilobytes_per_second = amount_since_last_update / (last_update_difference as usize).max(1);
let remaining = max - current_amount; // bytes
let time_remaining = (remaining / 1000) / kilobytes_per_second.max(1);
self.sender
.send(DownloadManagerSignal::UpdateUIStats(
kilobytes_per_second,
time_remaining,
))
.unwrap();
}
self.points_towards_update
.fetch_sub(to_update, Ordering::Relaxed);
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
pub fn set_time_now(&self) {
@ -95,7 +127,8 @@ impl ProgressObject {
}
pub fn set_max(&self, new_max: usize) {
*self.max.lock().unwrap() = new_max;
*self.points_to_push_update.lock().unwrap() = new_max / PROGRESS_UPDATES;
self.points_to_push_update
.store(new_max / PROGRESS_UPDATES, Ordering::Relaxed);
info!("points to push update: {}", new_max / PROGRESS_UPDATES);
}
pub fn set_size(&self, length: usize) {

View file

@ -3,11 +3,11 @@ use std::{
sync::{Arc, Mutex, MutexGuard},
};
use super::download_manager::GameDownloadAgentQueueStandin;
use super::downloadable_metadata::DownloadableMetadata;
#[derive(Clone)]
pub struct Queue {
inner: Arc<Mutex<VecDeque<Arc<GameDownloadAgentQueueStandin>>>>,
inner: Arc<Mutex<VecDeque<DownloadableMetadata>>>,
}
#[allow(dead_code)]
@ -17,49 +17,52 @@ impl Queue {
inner: Arc::new(Mutex::new(VecDeque::new())),
}
}
pub fn read(&self) -> VecDeque<Arc<GameDownloadAgentQueueStandin>> {
pub fn read(&self) -> VecDeque<DownloadableMetadata> {
self.inner.lock().unwrap().clone()
}
pub fn edit(&self) -> MutexGuard<'_, VecDeque<Arc<GameDownloadAgentQueueStandin>>> {
pub fn edit(&self) -> MutexGuard<'_, VecDeque<DownloadableMetadata>> {
self.inner.lock().unwrap()
}
pub fn pop_front(&self) -> Option<Arc<GameDownloadAgentQueueStandin>> {
pub fn pop_front(&self) -> Option<DownloadableMetadata> {
self.edit().pop_front()
}
pub fn empty(&self) -> bool {
self.inner.lock().unwrap().len() == 0
}
pub fn exists(&self, meta: DownloadableMetadata) -> bool {
self.read().contains(&meta)
}
/// Either inserts `interface` at the specified index, or appends to
/// the back of the deque if index is greater than the length of the deque
pub fn insert(&self, interface: GameDownloadAgentQueueStandin, index: usize) {
pub fn insert(&self, interface: DownloadableMetadata, index: usize) {
if self.read().len() > index {
self.append(interface);
} else {
self.edit().insert(index, Arc::new(interface));
self.edit().insert(index, interface);
}
}
pub fn append(&self, interface: GameDownloadAgentQueueStandin) {
self.edit().push_back(Arc::new(interface));
pub fn append(&self, interface: DownloadableMetadata) {
self.edit().push_back(interface);
}
pub fn pop_front_if_equal(
&self,
game_id: String,
) -> Option<Arc<GameDownloadAgentQueueStandin>> {
meta: &DownloadableMetadata,
) -> Option<DownloadableMetadata> {
let mut queue = self.edit();
let front = match queue.front() {
Some(front) => front,
None => return None,
};
if front.id == game_id {
if front == meta {
return queue.pop_front();
}
None
}
pub fn get_by_id(&self, game_id: String) -> Option<usize> {
self.read().iter().position(|data| data.id == game_id)
pub fn get_by_meta(&self, meta: &DownloadableMetadata) -> Option<usize> {
self.read().iter().position(|data| data == meta)
}
pub fn move_to_index_by_id(&self, game_id: String, new_index: usize) -> Result<(), ()> {
let index = match self.get_by_id(game_id) {
pub fn move_to_index_by_meta(&self, meta: &DownloadableMetadata, new_index: usize) -> Result<(), ()> {
let index = match self.get_by_meta(meta) {
Some(index) => index,
None => return Err(()),
};

View file

@ -1,335 +0,0 @@
use crate::auth::generate_authorization_header;
use crate::db::DatabaseImpls;
use crate::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::downloads::progress_object::ProgressHandle;
use crate::remote::RemoteAccessError;
use crate::DB;
use core::time;
use log::{debug, error, info};
use rayon::ThreadPoolBuilder;
use serde::ser::{Error, SerializeMap};
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use std::fs::{create_dir_all, File};
use std::io;
use std::path::Path;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use urlencoding::encode;
#[cfg(target_os = "linux")]
use rustix::fs::{fallocate, FallocateFlags};
use super::download_logic::download_game_chunk;
use super::download_manager::DownloadManagerSignal;
use super::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use super::progress_object::ProgressObject;
use super::stored_manifest::StoredManifest;
pub struct GameDownloadAgent {
pub id: String,
pub version: String,
pub control_flag: DownloadThreadControl,
contexts: Vec<DropDownloadContext>,
completed_contexts: Mutex<Vec<usize>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
sender: Sender<DownloadManagerSignal>,
pub stored_manifest: StoredManifest,
}
#[derive(Debug)]
pub enum GameDownloadError {
Communication(RemoteAccessError),
Checksum,
Setup(SetupError),
Lock,
IoError(io::Error),
DownloadError,
}
#[derive(Debug)]
pub enum SetupError {
Context,
}
impl Display for GameDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
GameDownloadError::Communication(error) => write!(f, "{}", error),
GameDownloadError::Setup(error) => write!(f, "An error occurred while setting up the download: {}", error),
GameDownloadError::Lock => write!(f, "Failed to acquire lock. Something has gone very wrong internally. Please restart the application"),
GameDownloadError::Checksum => write!(f, "Checksum failed to validate for download"),
GameDownloadError::IoError(error) => write!(f, "{}", error),
GameDownloadError::DownloadError => write!(f, "Download failed. See Download Manager status for specific error"),
}
}
}
impl Display for SetupError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SetupError::Context => write!(f, "Failed to generate contexts for download"),
}
}
}
impl GameDownloadAgent {
pub fn new(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Self {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
let db_lock = DB.borrow_data().unwrap();
let base_dir = db_lock.games.install_dirs[target_download_dir].clone();
drop(db_lock);
let base_dir_path = Path::new(&base_dir);
let data_base_dir_path = base_dir_path.join(id.clone());
let stored_manifest =
StoredManifest::generate(id.clone(), version.clone(), data_base_dir_path.clone());
Self {
id,
version,
control_flag,
manifest: Mutex::new(None),
contexts: Vec::new(),
completed_contexts: Mutex::new(Vec::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
stored_manifest,
}
}
// Blocking
pub fn setup_download(&mut self) -> Result<(), GameDownloadError> {
self.ensure_manifest_exists()?;
info!("Ensured manifest exists");
self.ensure_contexts()?;
info!("Ensured contexts exists");
self.control_flag.set(DownloadThreadControlFlag::Go);
Ok(())
}
// Blocking
pub fn download(&mut self) -> Result<(), GameDownloadError> {
self.setup_download()?;
self.set_progress_object_params();
let timer = Instant::now();
self.run().map_err(|_| GameDownloadError::DownloadError)?;
info!(
"{} took {}ms to download",
self.id,
timer.elapsed().as_millis()
);
Ok(())
}
pub fn ensure_manifest_exists(&self) -> Result<(), GameDownloadError> {
if self.manifest.lock().unwrap().is_some() {
return Ok(());
}
self.download_manifest()
}
fn download_manifest(&self) -> Result<(), GameDownloadError> {
let base_url = DB.fetch_base_url();
let manifest_url = base_url
.join(
format!(
"/api/v1/client/metadata/manifest?id={}&version={}",
self.id,
encode(&self.version)
)
.as_str(),
)
.unwrap();
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
let response = client
.get(manifest_url.to_string())
.header("Authorization", header)
.send()
.unwrap();
if response.status() != 200 {
return Err(GameDownloadError::Communication(
RemoteAccessError::ManifestDownloadFailed(
response.status(),
response.text().unwrap(),
),
));
}
let manifest_download = response.json::<DropManifest>().unwrap();
if let Ok(mut manifest) = self.manifest.lock() {
*manifest = Some(manifest_download);
return Ok(());
}
Err(GameDownloadError::Lock)
}
fn set_progress_object_params(&self) {
// Avoid re-setting it
if self.progress.get_max() != 0 {
return;
}
let length = self.contexts.len();
let chunk_count = self.contexts.iter().map(|chunk| chunk.length).sum();
debug!("Setting ProgressObject max to {}", chunk_count);
self.progress.set_max(chunk_count);
debug!("Setting ProgressObject size to {}", length);
self.progress.set_size(length);
debug!("Setting ProgressObject time to now");
self.progress.set_time_now();
}
pub fn ensure_contexts(&mut self) -> Result<(), GameDownloadError> {
if !self.contexts.is_empty() {
return Ok(());
}
self.generate_contexts()?;
Ok(())
}
pub fn generate_contexts(&mut self) -> Result<(), GameDownloadError> {
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.stored_manifest.base_path);
create_dir_all(base_path).unwrap();
*self.completed_contexts.lock().unwrap() = self.stored_manifest.get_completed_contexts();
info!(
"Completed contexts: {:?}",
*self.completed_contexts.lock().unwrap()
);
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
let container = path.parent().unwrap();
create_dir_all(container).unwrap();
let file = File::create(path.clone()).unwrap();
let mut 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(),
length: *length,
permissions: chunk.permissions,
});
running_offset += *length as u64;
}
#[cfg(target_os = "linux")]
if running_offset > 0 {
let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset);
}
}
self.contexts = contexts;
Ok(())
}
pub fn run(&self) -> Result<(), ()> {
info!("downloading game: {}", self.id);
const DOWNLOAD_MAX_THREADS: usize = 1;
let pool = ThreadPoolBuilder::new()
.num_threads(DOWNLOAD_MAX_THREADS)
.build()
.unwrap();
let completed_indexes = Arc::new(Mutex::new(Vec::new()));
let completed_indexes_loop_arc = completed_indexes.clone();
pool.scope(move |scope| {
let completed_lock = self.completed_contexts.lock().unwrap();
for (index, context) in self.contexts.iter().enumerate() {
let progress = self.progress.get(index); // Clone arcs
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
// If we've done this one already, skip it
if completed_lock.contains(&index) {
progress_handle.add(context.length);
continue;
}
let context = context.clone();
let control_flag = self.control_flag.clone(); // Clone arcs
let completed_indexes_ref = completed_indexes_loop_arc.clone();
scope.spawn(move |_| {
match download_game_chunk(context.clone(), control_flag, progress_handle) {
Ok(res) => {
if res {
let mut lock = completed_indexes_ref.lock().unwrap();
lock.push(index);
}
}
Err(e) => {
error!("GameDownloadError: {}", e);
self.sender.send(DownloadManagerSignal::Error(e)).unwrap();
}
}
});
}
});
let completed_lock_len = {
let mut completed_lock = self.completed_contexts.lock().unwrap();
let newly_completed_lock = completed_indexes.lock().unwrap();
completed_lock.extend(newly_completed_lock.iter());
completed_lock.len()
};
// If we're not out of contexts, we're not done, so we don't fire completed
if completed_lock_len != self.contexts.len() {
info!("da for {} exited without completing", self.id.clone());
self.stored_manifest
.set_completed_contexts(&self.completed_contexts);
info!("Setting completed contexts");
self.stored_manifest.write();
info!("Wrote completed contexts");
return Ok(());
}
// We've completed
self.sender
.send(DownloadManagerSignal::Completed(self.id.clone()))
.unwrap();
Ok(())
}
}

View file

@ -1,417 +0,0 @@
use std::{
collections::HashMap,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex, RwLockWriteGuard,
},
thread::{spawn, JoinHandle},
};
use log::{error, info};
use tauri::{AppHandle, Emitter};
use crate::{
db::{Database, GameStatus, GameTransientStatus},
library::{on_game_complete, GameUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData},
state::GameStatusManager,
DB,
};
use super::{
download_agent::{GameDownloadAgent, GameDownloadError},
download_manager::{
DownloadManager, DownloadManagerSignal, DownloadManagerStatus,
GameDownloadAgentQueueStandin, GameDownloadStatus,
},
download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag},
progress_object::ProgressObject,
queue::Queue,
};
/*
Welcome to the download manager, the most overengineered, glorious piece of bullshit.
The download manager takes a queue of game_ids and their associated
GameDownloadAgents, and then, one-by-one, executes them. It provides an interface
to interact with the currently downloading agent, and manage the queue.
When the DownloadManager is initialised, it is designed to provide a reference
which can be used to provide some instructions (the DownloadManagerInterface),
but other than that, it runs without any sort of interruptions.
It does this by opening up two data structures. Primarily is the command_receiver,
and mpsc (multi-channel-single-producer) which allows commands to be sent from
the Interface, and queued up for the Manager to process.
These have been mapped in the DownloadManagerSignal docs.
The other way to interact with the DownloadManager is via the donwload_queue,
which is just a collection of ids which may be rearranged to suit
whichever download queue order is required.
+----------------------------------------------------------------------------+
| DO NOT ATTEMPT TO ADD OR REMOVE FROM THE QUEUE WITHOUT USING SIGNALS!! |
| THIS WILL CAUSE A DESYNC BETWEEN THE DOWNLOAD AGENT REGISTRY AND THE QUEUE |
| WHICH HAS NOT BEEN ACCOUNTED FOR |
+----------------------------------------------------------------------------+
This download queue does not actually own any of the GameDownloadAgents. It is
simply a id-based reference system. The actual Agents are stored in the
download_agent_registry HashMap, as ordering is no issue here. This is why
appending or removing from the download_queue must be done via signals.
Behold, my madness - quexeky
*/
// Refactored to consolidate this type. It's a monster.
pub type CurrentProgressObject = Arc<Mutex<Option<Arc<ProgressObject>>>>;
pub struct DownloadManagerBuilder {
download_agent_registry: HashMap<String, Arc<Mutex<GameDownloadAgent>>>,
download_queue: Queue,
command_receiver: Receiver<DownloadManagerSignal>,
sender: Sender<DownloadManagerSignal>,
progress: CurrentProgressObject,
status: Arc<Mutex<DownloadManagerStatus>>,
app_handle: AppHandle,
current_download_agent: Option<Arc<GameDownloadAgentQueueStandin>>, // Should be the only game download agent in the map with the "Go" flag
current_download_thread: Mutex<Option<JoinHandle<()>>>,
active_control_flag: Option<DownloadThreadControl>,
}
impl DownloadManagerBuilder {
pub fn build(app_handle: AppHandle) -> DownloadManager {
let queue = Queue::new();
let (command_sender, command_receiver) = channel();
let active_progress = Arc::new(Mutex::new(None));
let status = Arc::new(Mutex::new(DownloadManagerStatus::Empty));
let manager = Self {
download_agent_registry: HashMap::new(),
download_queue: queue.clone(),
command_receiver,
status: status.clone(),
sender: command_sender.clone(),
progress: active_progress.clone(),
app_handle,
current_download_agent: None,
current_download_thread: Mutex::new(None),
active_control_flag: None,
};
let terminator = spawn(|| manager.manage_queue());
DownloadManager::new(terminator, queue, active_progress, command_sender)
}
fn set_game_status<F: FnOnce(&mut RwLockWriteGuard<'_, Database>, &String) -> ()>(
&self,
id: String,
setter: F,
) {
let mut db_handle = DB.borrow_data_mut().unwrap();
setter(&mut db_handle, &id);
drop(db_handle);
DB.save().unwrap();
let status = GameStatusManager::fetch_state(&id);
self.app_handle
.emit(
&format!("update_game/{}", id),
GameUpdateEvent {
game_id: id,
status,
},
)
.unwrap();
}
fn push_manager_update(&self) {
let queue = self.download_queue.read();
let queue_objs: Vec<QueueUpdateEventQueueData> = queue
.iter()
.map(|interface| QueueUpdateEventQueueData {
id: interface.id.clone(),
status: interface.status.lock().unwrap().clone(),
progress: interface.progress.get_progress(),
})
.collect();
let event_data = QueueUpdateEvent { queue: queue_objs };
self.app_handle.emit("update_queue", event_data).unwrap();
}
fn stop_and_wait_current_download(&self) {
self.set_status(DownloadManagerStatus::Paused);
if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop);
}
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
drop(download_thread_lock);
}
fn sync_download_agent(&self) {}
fn remove_and_cleanup_game(&mut self, game_id: &String) -> Arc<Mutex<GameDownloadAgent>> {
self.download_queue.pop_front();
let download_agent = self.download_agent_registry.remove(game_id).unwrap();
self.cleanup_current_download();
download_agent
}
// CAREFUL WITH THIS FUNCTION
// Make sure the download thread is terminated
fn cleanup_current_download(&mut self) {
self.active_control_flag = None;
*self.progress.lock().unwrap() = None;
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = None;
drop(download_thread_lock);
}
fn manage_queue(mut self) -> Result<(), ()> {
loop {
let signal = match self.command_receiver.recv() {
Ok(signal) => signal,
Err(_) => return Err(()),
};
match signal {
DownloadManagerSignal::Go => {
self.manage_go_signal();
}
DownloadManagerSignal::Stop => {
self.manage_stop_signal();
}
DownloadManagerSignal::Completed(game_id) => {
self.manage_completed_signal(game_id);
}
DownloadManagerSignal::Queue(game_id, version, target_download_dir) => {
self.manage_queue_signal(game_id, version, target_download_dir);
}
DownloadManagerSignal::Error(e) => {
self.manage_error_signal(e);
}
DownloadManagerSignal::Cancel => {
self.manage_cancel_signal();
}
DownloadManagerSignal::Update => {
self.push_manager_update();
}
DownloadManagerSignal::Finish => {
self.stop_and_wait_current_download();
return Ok(());
}
DownloadManagerSignal::Remove(game_id) => {
self.manage_remove_game(game_id);
}
};
}
}
fn manage_remove_game(&mut self, game_id: String) {
if let Some(current_download) = &self.current_download_agent {
if current_download.id == game_id {
self.manage_cancel_signal();
}
}
let index = self.download_queue.get_by_id(game_id.clone()).unwrap();
let mut queue_handle = self.download_queue.edit();
queue_handle.remove(index);
self.set_game_status(game_id, |db_handle, id| {
db_handle.games.transient_statuses.remove(id);
});
drop(queue_handle);
if self.current_download_agent.is_none() {
self.manage_go_signal();
}
self.push_manager_update();
}
fn manage_stop_signal(&mut self) {
info!("Got signal 'Stop'");
self.set_status(DownloadManagerStatus::Paused);
if let Some(active_control_flag) = self.active_control_flag.clone() {
active_control_flag.set(DownloadThreadControlFlag::Stop);
}
}
fn manage_completed_signal(&mut self, game_id: String) {
info!("Got signal 'Completed'");
if let Some(interface) = &self.current_download_agent {
// When if let chains are stabilised, combine these two statements
if interface.id == game_id {
info!("Popping consumed data");
let download_agent = self.remove_and_cleanup_game(&game_id);
let download_agent_lock = download_agent.lock().unwrap();
let version = download_agent_lock.version.clone();
let install_dir = download_agent_lock.stored_manifest.base_path.clone().to_string_lossy().to_string();
drop(download_agent_lock);
if let Err(error) =
on_game_complete(game_id, version, install_dir, &self.app_handle)
{
self.sender
.send(DownloadManagerSignal::Error(
GameDownloadError::Communication(error),
))
.unwrap();
}
}
}
self.sender.send(DownloadManagerSignal::Update).unwrap();
self.sender.send(DownloadManagerSignal::Go).unwrap();
}
fn manage_queue_signal(&mut self, id: String, version: String, target_download_dir: usize) {
info!("Got signal Queue");
let download_agent = Arc::new(Mutex::new(GameDownloadAgent::new(
id.clone(),
version,
target_download_dir,
self.sender.clone(),
)));
let download_agent_lock = download_agent.lock().unwrap();
let agent_status = GameDownloadStatus::Queued;
let interface_data = GameDownloadAgentQueueStandin {
id: id.clone(),
status: Mutex::new(agent_status),
progress: download_agent_lock.progress.clone(),
};
let version_name = download_agent_lock.version.clone();
drop(download_agent_lock);
self.download_agent_registry
.insert(interface_data.id.clone(), download_agent);
self.download_queue.append(interface_data);
self.set_game_status(id, |db, id| {
db.games.transient_statuses.insert(
id.to_string(),
GameTransientStatus::Downloading { version_name },
);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
fn manage_go_signal(&mut self) {
if !(!self.download_agent_registry.is_empty() && !self.download_queue.empty()) {
return;
}
if self.current_download_agent.is_some() {
info!("skipping go signal due to existing download job");
return;
}
info!("current download queue: {:?}", self.download_queue.read());
let agent_data = self.download_queue.read().front().unwrap().clone();
info!("starting download for {}", agent_data.id.clone());
let download_agent = self
.download_agent_registry
.get(&agent_data.id)
.unwrap()
.clone();
let download_agent_lock = download_agent.lock().unwrap();
self.current_download_agent = Some(agent_data);
// Cloning option should be okay because it only clones the Arc inside, not the AgentInterfaceData
let agent_data = self.current_download_agent.clone().unwrap();
let version_name = download_agent_lock.version.clone();
let progress_object = download_agent_lock.progress.clone();
*self.progress.lock().unwrap() = Some(progress_object);
let active_control_flag = download_agent_lock.control_flag.clone();
self.active_control_flag = Some(active_control_flag.clone());
let sender = self.sender.clone();
drop(download_agent_lock);
info!("Spawning download");
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = Some(spawn(move || {
let mut download_agent_lock = download_agent.lock().unwrap();
match download_agent_lock.download() {
// Returns once we've exited the download
// (not necessarily completed)
// The download agent will fire the completed event for us
Ok(_) => {}
// If an error occurred while *starting* the download
Err(err) => {
error!("error while managing download: {}", err);
sender.send(DownloadManagerSignal::Error(err)).unwrap();
}
};
drop(download_agent_lock);
}));
// Set status for games
for queue_game in self.download_queue.read() {
let mut status_handle = queue_game.status.lock().unwrap();
if queue_game.id == agent_data.id {
*status_handle = GameDownloadStatus::Downloading;
} else {
*status_handle = GameDownloadStatus::Queued;
}
drop(status_handle);
}
// Set flags for download manager
active_control_flag.set(DownloadThreadControlFlag::Go);
self.set_status(DownloadManagerStatus::Downloading);
self.set_game_status(agent_data.id.clone(), |db, id| {
db.games.transient_statuses.insert(
id.to_string(),
GameTransientStatus::Downloading { version_name },
);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
fn manage_error_signal(&mut self, error: GameDownloadError) {
let current_status = self.current_download_agent.clone().unwrap();
self.remove_and_cleanup_game(&current_status.id); // Remove all the locks and shit
let mut lock = current_status.status.lock().unwrap();
*lock = GameDownloadStatus::Error;
self.set_status(DownloadManagerStatus::Error(error));
let game_id = current_status.id.clone();
self.set_game_status(game_id, |db_handle, id| {
db_handle.games.transient_statuses.remove(id);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
}
fn manage_cancel_signal(&mut self) {
self.stop_and_wait_current_download();
info!("cancel waited for download to finish");
self.cleanup_current_download();
}
fn set_status(&self, status: DownloadManagerStatus) {
*self.status.lock().unwrap() = status;
}
}

View file

@ -1,10 +0,0 @@
pub mod download_agent;
pub mod download_commands;
mod download_logic;
pub mod download_manager;
pub mod download_manager_builder;
mod download_thread_control_flag;
mod manifest;
mod progress_object;
pub mod queue;
mod stored_manifest;

View file

@ -0,0 +1,404 @@
use crate::auth::generate_authorization_header;
use crate::db::{set_game_status, GameDownloadStatus, ApplicationTransientStatus, DatabaseImpls};
use crate::download_manager::application_download_error::ApplicationDownloadError;
use crate::download_manager::download_manager::{DownloadManagerSignal, DownloadStatus};
use crate::download_manager::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use crate::download_manager::downloadable::Downloadable;
use crate::download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata};
use crate::download_manager::progress_object::{ProgressHandle, ProgressObject};
use crate::games::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::games::library::{on_game_complete, push_game_update};
use crate::remote::RemoteAccessError;
use crate::DB;
use log::{debug, error, info};
use rayon::ThreadPoolBuilder;
use tauri::{AppHandle, Emitter};
use std::collections::VecDeque;
use std::fs::{create_dir_all, remove_dir_all, File};
use std::path::Path;
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
use std::thread::spawn;
use std::time::Instant;
use urlencoding::encode;
#[cfg(target_os = "linux")]
use rustix::fs::{fallocate, FallocateFlags};
use super::download_logic::download_game_chunk;
use super::stored_manifest::StoredManifest;
pub struct GameDownloadAgent {
pub id: String,
pub version: String,
pub control_flag: DownloadThreadControl,
contexts: Mutex<Vec<DropDownloadContext>>,
completed_contexts: Mutex<VecDeque<usize>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
sender: Sender<DownloadManagerSignal>,
pub stored_manifest: StoredManifest,
status: Mutex<DownloadStatus>
}
impl GameDownloadAgent {
pub fn new(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Self {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
let db_lock = DB.borrow_data().unwrap();
let base_dir = db_lock.applications.install_dirs[target_download_dir].clone();
drop(db_lock);
let base_dir_path = Path::new(&base_dir);
let data_base_dir_path = base_dir_path.join(id.clone());
let stored_manifest =
StoredManifest::generate(id.clone(), version.clone(), data_base_dir_path.clone());
Self {
id,
version,
control_flag,
manifest: Mutex::new(None),
contexts: Mutex::new(Vec::new()),
completed_contexts: Mutex::new(VecDeque::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
stored_manifest,
status: Mutex::new(DownloadStatus::Queued),
}
}
// Blocking
pub fn setup_download(&self) -> Result<(), ApplicationDownloadError> {
self.ensure_manifest_exists()?;
info!("Ensured manifest exists");
self.ensure_contexts()?;
info!("Ensured contexts exists");
self.control_flag.set(DownloadThreadControlFlag::Go);
Ok(())
}
// Blocking
pub fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
info!("Setting up download");
self.setup_download()?;
info!("Setting progress object params");
self.set_progress_object_params();
info!("Running");
let timer = Instant::now();
push_game_update(app_handle, &self.metadata(), (None, Some(ApplicationTransientStatus::Downloading { version_name: self.version.clone() })));
let res = self.run().map_err(|_| ApplicationDownloadError::DownloadError);
info!(
"{} took {}ms to download",
self.id,
timer.elapsed().as_millis()
);
res
}
pub fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
if self.manifest.lock().unwrap().is_some() {
return Ok(());
}
self.download_manifest()
}
fn download_manifest(&self) -> Result<(), ApplicationDownloadError> {
let base_url = DB.fetch_base_url();
let manifest_url = base_url
.join(
format!(
"/api/v1/client/metadata/manifest?id={}&version={}",
self.id,
encode(&self.version)
)
.as_str(),
)
.unwrap();
let header = generate_authorization_header();
let client = reqwest::blocking::Client::new();
let response = client
.get(manifest_url.to_string())
.header("Authorization", header)
.send()
.unwrap();
if response.status() != 200 {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::ManifestDownloadFailed(
response.status(),
response.text().unwrap(),
),
));
}
let manifest_download = response.json::<DropManifest>().unwrap();
if let Ok(mut manifest) = self.manifest.lock() {
*manifest = Some(manifest_download);
return Ok(());
}
Err(ApplicationDownloadError::Lock)
}
fn set_progress_object_params(&self) {
// Avoid re-setting it
if self.progress.get_max() != 0 {
return;
}
let contexts = self.contexts.lock().unwrap();
let length = contexts.len();
let chunk_count = contexts.iter().map(|chunk| chunk.length).sum();
debug!("Setting ProgressObject max to {}", chunk_count);
self.progress.set_max(chunk_count);
debug!("Setting ProgressObject size to {}", length);
self.progress.set_size(length);
debug!("Setting ProgressObject time to now");
self.progress.set_time_now();
}
pub fn ensure_contexts(&self) -> Result<(), ApplicationDownloadError> {
if !self.contexts.lock().unwrap().is_empty() {
return Ok(());
}
self.generate_contexts()?;
Ok(())
}
pub fn generate_contexts(&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.stored_manifest.base_path);
create_dir_all(base_path).unwrap();
{
let mut completed_contexts_lock = self.completed_contexts.lock().unwrap();
completed_contexts_lock.clear();
completed_contexts_lock
.extend(self.stored_manifest.get_completed_contexts());
}
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
let container = path.parent().unwrap();
create_dir_all(container).unwrap();
let file = File::create(path.clone()).unwrap();
let mut 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(),
length: *length,
permissions: chunk.permissions,
});
running_offset += *length as u64;
}
#[cfg(target_os = "linux")]
if running_offset > 0 {
let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset);
}
}
*self.contexts.lock().unwrap() = contexts;
Ok(())
}
pub fn run(&self) -> Result<bool, ()> {
info!("downloading game: {}", self.id);
const DOWNLOAD_MAX_THREADS: usize = 1;
let pool = ThreadPoolBuilder::new()
.num_threads(DOWNLOAD_MAX_THREADS)
.build()
.unwrap();
let completed_indexes = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_indexes.clone();
let base_url = DB.fetch_base_url();
let contexts = self.contexts.lock().unwrap();
pool.scope(|scope| {
let client = &reqwest::blocking::Client::new();
for (index, context) in contexts.iter().enumerate() {
let client = client.clone();
let completed_indexes = 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
if self.completed_contexts.lock().unwrap().contains(&index) {
progress_handle.add(context.length);
continue;
}
let sender = self.sender.clone();
let request = generate_request(&base_url, client, &context);
scope.spawn(move |_| {
match download_game_chunk(context, &self.control_flag, progress_handle, request) {
Ok(res) => {
if res {
completed_indexes.push(index);
}
}
Err(e) => {
error!("{}", e);
sender.send(DownloadManagerSignal::Error(e)).unwrap();
}
}
info!("Completed context id {}", index);
});
}
});
let newly_completed = completed_indexes.to_owned();
let completed_lock_len = {
let mut completed_contexts_lock = self.completed_contexts.lock().unwrap();
for (item, _) in newly_completed.iter() {
completed_contexts_lock.push_front(item);
}
completed_contexts_lock.len()
};
info!("Got newly completed");
// If we're not out of contexts, we're not done, so we don't fire completed
if completed_lock_len != contexts.len() {
info!("da for {} exited without completing", self.id.clone());
self.stored_manifest
.set_completed_contexts(&self.completed_contexts.lock().unwrap().clone().into());
info!("Setting completed contexts");
self.stored_manifest.write();
info!("Wrote completed contexts");
return Ok(false);
}
info!("Sending completed signal");
// We've completed
self.sender
.send(DownloadManagerSignal::Completed(self.metadata()))
.unwrap();
Ok(true)
}
}
fn generate_request(base_url: &url::Url, client: reqwest::blocking::Client, context: &DropDownloadContext) -> reqwest::blocking::RequestBuilder {
let chunk_url = base_url
.join(&format!(
"/api/v1/client/chunk?id={}&version={}&name={}&chunk={}",
// Encode the parts we don't trust
context.game_id,
encode(&context.version),
encode(&context.file_name),
context.index
))
.unwrap();
let header = generate_authorization_header();
let request = client
.get(chunk_url)
.header("Authorization", header);
request
}
impl Downloadable for GameDownloadAgent {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
*self.status.lock().unwrap() = DownloadStatus::Downloading;
self.download(app_handle)
}
fn progress(&self) -> Arc<ProgressObject> {
self.progress.clone()
}
fn control_flag(&self) -> DownloadThreadControl {
self.control_flag.clone()
}
fn metadata(&self) -> DownloadableMetadata {
DownloadableMetadata {
id: self.id.clone(),
version: Some(self.version.clone()),
download_type: DownloadType::Game,
}
}
fn on_initialised(&self, _app_handle: &tauri::AppHandle) {
*self.status.lock().unwrap() = DownloadStatus::Queued;
return;
}
fn on_error(&self, app_handle: &tauri::AppHandle, error: ApplicationDownloadError) {
*self.status.lock().unwrap() = DownloadStatus::Error;
app_handle
.emit("download_error", error.to_string())
.unwrap();
error!("error while managing download: {}", error);
set_game_status(app_handle, self.metadata(), |db_handle, meta| {
db_handle.applications.transient_statuses.remove(meta);
});
}
fn on_complete(&self, app_handle: &tauri::AppHandle) {
on_game_complete(&self.metadata(), self.stored_manifest.base_path.to_string_lossy().to_string(), app_handle).unwrap();
}
fn on_incomplete(&self, _app_handle: &tauri::AppHandle) {
*self.status.lock().unwrap() = DownloadStatus::Queued;
return;
}
fn on_cancelled(&self, _app_handle: &tauri::AppHandle) {
return;
}
fn status(&self) -> DownloadStatus {
self.status.lock().unwrap().clone()
}
}

View file

@ -1,6 +1,8 @@
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use crate::AppState;
use crate::{download_manager::{downloadable::Downloadable, downloadable_metadata::DownloadableMetadata}, AppState};
use super::download_agent::GameDownloadAgent;
#[tauri::command]
pub fn download_game(
@ -9,11 +11,15 @@ pub fn download_game(
install_dir: usize,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let sender = state.lock().unwrap().download_manager.get_sender();
let game_download_agent = Arc::new(
Box::new(GameDownloadAgent::new(game_id, game_version, install_dir, sender)) as Box<dyn Downloadable + Send + Sync>
);
state
.lock()
.unwrap()
.download_manager
.queue_game(game_id, game_version, install_dir)
.queue_download(game_download_agent)
.map_err(|_| "An error occurred while communicating with the download manager.".to_string())
}
@ -41,8 +47,8 @@ pub fn move_game_in_queue(
}
#[tauri::command]
pub fn cancel_game(state: tauri::State<'_, Mutex<AppState>>, game_id: String) {
state.lock().unwrap().download_manager.cancel(game_id)
pub fn cancel_game(state: tauri::State<'_, Mutex<AppState>>, meta: DownloadableMetadata) {
state.lock().unwrap().download_manager.cancel(meta)
}
/*

View file

@ -1,19 +1,20 @@
use crate::auth::generate_authorization_header;
use crate::db::DatabaseImpls;
use crate::downloads::manifest::DropDownloadContext;
use crate::remote::RemoteAccessError;
use crate::download_manager::application_download_error::ApplicationDownloadError;
use crate::download_manager::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use crate::download_manager::progress_object::ProgressHandle;
use crate::games::downloads::manifest::DropDownloadContext;
use crate::remote::{DropServerError, RemoteAccessError};
use crate::DB;
use log::warn;
use log::{error, info, warn};
use md5::{Context, Digest};
use reqwest::blocking::Response;
use tauri::utils::acl::Permission;
use reqwest::blocking::{Client, Request, RequestBuilder, Response};
use std::fs::{set_permissions, Permissions};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::thread::sleep;
use std::time::Duration;
use std::sync::Arc;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write},
@ -21,10 +22,6 @@ use std::{
};
use urlencoding::encode;
use super::download_agent::GameDownloadError;
use super::download_thread_control_flag::{DownloadThreadControl, DownloadThreadControlFlag};
use super::progress_object::ProgressHandle;
pub struct DropWriter<W: Write> {
hasher: Context,
destination: W,
@ -68,18 +65,18 @@ impl Seek for DropWriter<File> {
}
}
pub struct DropDownloadPipeline<R: Read, W: Write> {
pub struct DropDownloadPipeline<'a, R: Read, W: Write> {
pub source: R,
pub destination: DropWriter<W>,
pub control_flag: DownloadThreadControl,
pub control_flag: &'a DownloadThreadControl,
pub progress: ProgressHandle,
pub size: usize,
}
impl DropDownloadPipeline<Response, File> {
impl<'a> DropDownloadPipeline<'a, Response, File> {
fn new(
source: Response,
destination: DropWriter<File>,
control_flag: DownloadThreadControl,
control_flag: &'a DownloadThreadControl,
progress: ProgressHandle,
size: usize,
) -> Self {
@ -124,41 +121,24 @@ impl DropDownloadPipeline<Response, File> {
}
pub fn download_game_chunk(
ctx: DropDownloadContext,
control_flag: DownloadThreadControl,
ctx: &DropDownloadContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
) -> Result<bool, GameDownloadError> {
request: RequestBuilder
) -> Result<bool, ApplicationDownloadError> {
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let base_url = DB.fetch_base_url();
let client = reqwest::blocking::Client::new();
let chunk_url = base_url
.join(&format!(
"/api/v1/client/chunk?id={}&version={}&name={}&chunk={}",
// Encode the parts we don't trust
ctx.game_id,
encode(&ctx.version),
encode(&ctx.file_name),
ctx.index
))
.unwrap();
let header = generate_authorization_header();
let response = client
.get(chunk_url)
.header("Authorization", header)
let response = request
.send()
.map_err(|e| GameDownloadError::Communication(e.into()))?;
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
warn!("{}", response.text().unwrap());
return Err(GameDownloadError::Communication(
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidCodeError(400),
));
}
@ -173,8 +153,9 @@ pub fn download_game_chunk(
let content_length = response.content_length();
if content_length.is_none() {
return Err(GameDownloadError::Communication(
RemoteAccessError::InvalidResponse,
error!("Recieved 0 length content from server");
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(response.json::<DropServerError>().unwrap()),
));
}
@ -186,7 +167,9 @@ pub fn download_game_chunk(
content_length.unwrap().try_into().unwrap(),
);
let completed = pipeline.copy().map_err(GameDownloadError::IoError)?;
let completed = pipeline
.copy()
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
if !completed {
return Ok(false);
};
@ -195,7 +178,7 @@ pub fn download_game_chunk(
#[cfg(unix)]
{
let permissions = Permissions::from_mode(ctx.permissions);
set_permissions(ctx.path, permissions).unwrap();
set_permissions(ctx.path.clone(), permissions).unwrap();
}
/*

View file

@ -0,0 +1,5 @@
pub mod download_agent;
pub mod download_commands;
mod download_logic;
mod manifest;
mod stored_manifest;

View file

@ -1,12 +1,11 @@
use std::{
default,
fs::File,
io::{Read, Write},
path::{Path, PathBuf},
path::PathBuf,
sync::Mutex,
};
use log::{error, info};
use log::error;
use serde::{Deserialize, Serialize};
use serde_binary::binary_stream::Endian;
@ -44,15 +43,15 @@ impl StoredManifest {
}
};
let manifest = match serde_binary::from_vec::<StoredManifest>(s, Endian::Little) {
match serde_binary::from_vec::<StoredManifest>(s, Endian::Little) {
Ok(manifest) => manifest,
Err(e) => {
error!("{}", e);
StoredManifest::new(game_id, game_version, base_path)
}
};
return manifest;
}
}
pub fn write(&self) {
let manifest_raw = match serde_binary::to_vec(&self, Endian::Little) {
@ -73,8 +72,8 @@ impl StoredManifest {
Err(e) => error!("{}", e),
};
}
pub fn set_completed_contexts(&self, completed_contexts: &Mutex<Vec<usize>>) {
*self.completed_contexts.lock().unwrap() = completed_contexts.lock().unwrap().clone();
pub fn set_completed_contexts(&self, completed_contexts: &Vec<usize>) {
*self.completed_contexts.lock().unwrap() = completed_contexts.clone();
}
pub fn get_completed_contexts(&self) -> Vec<usize> {
self.completed_contexts.lock().unwrap().clone()

View file

@ -1,17 +1,20 @@
use std::fs::remove_dir_all;
use std::sync::Mutex;
use std::thread::spawn;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use tauri::Emitter;
use tauri::{AppHandle, Manager};
use urlencoding::encode;
use crate::db::DatabaseImpls;
use crate::db::{ApplicationTransientStatus, DatabaseImpls, GameDownloadStatus};
use crate::db::GameVersion;
use crate::db::{GameStatus, GameTransientStatus};
use crate::downloads::download_manager::GameDownloadStatus;
use crate::download_manager::download_manager::DownloadStatus;
use crate::download_manager::downloadable_metadata::DownloadableMetadata;
use crate::process::process_manager::Platform;
use crate::remote::RemoteAccessError;
use crate::state::{GameStatusManager, GameStatusWithTransient};
use crate::games::state::{GameStatusManager, GameStatusWithTransient};
use crate::{auth::generate_authorization_header, AppState, DB};
#[derive(serde::Serialize)]
@ -37,13 +40,13 @@ pub struct Game {
#[derive(serde::Serialize, Clone)]
pub struct GameUpdateEvent {
pub game_id: String,
pub status: (Option<GameStatus>, Option<GameTransientStatus>),
pub status: (Option<GameDownloadStatus>, Option<ApplicationTransientStatus>),
}
#[derive(Serialize, Clone)]
pub struct QueueUpdateEventQueueData {
pub id: String,
pub status: GameDownloadStatus,
pub meta: DownloadableMetadata,
pub status: DownloadStatus,
pub progress: f64,
}
@ -52,6 +55,12 @@ pub struct QueueUpdateEvent {
pub queue: Vec<QueueUpdateEventQueueData>,
}
#[derive(serde::Serialize, Clone)]
pub struct StatsUpdateEvent {
pub speed: usize,
pub time: usize,
}
// Game version with some fields missing and size information
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
@ -91,11 +100,11 @@ fn fetch_library_logic(app: AppHandle) -> Result<Vec<Game>, RemoteAccessError> {
for game in games.iter() {
handle.games.insert(game.id.clone(), game.clone());
if !db_handle.games.statuses.contains_key(&game.id) {
if !db_handle.applications.game_statuses.contains_key(&game.id) {
db_handle
.games
.statuses
.insert(game.id.clone(), GameStatus::Remote {});
.applications
.game_statuses
.insert(game.id.clone(), GameDownloadStatus::Remote {});
}
}
@ -154,10 +163,10 @@ fn fetch_game_logic(
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.games
.statuses
.applications
.game_statuses
.entry(id.clone())
.or_insert(GameStatus::Remote {});
.or_insert(GameDownloadStatus::Remote {});
drop(db_handle);
let status = GameStatusManager::fetch_state(&id);
@ -171,8 +180,8 @@ fn fetch_game_logic(
}
#[tauri::command]
pub fn fetch_game(id: String, app: tauri::AppHandle) -> Result<FetchGameStruct, String> {
let result = fetch_game_logic(id, app);
pub fn fetch_game(game_id: String, app: tauri::AppHandle) -> Result<FetchGameStruct, String> {
let result = fetch_game_logic(game_id, app);
if result.is_err() {
return Err(result.err().unwrap().to_string());
@ -224,6 +233,89 @@ fn fetch_game_verion_options_logic<'a>(
Ok(data)
}
#[tauri::command]
pub fn uninstall_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
app_handle: AppHandle
) -> Result<(), String> {
let meta = get_current_meta(&game_id)?;
println!("{:?}", meta);
uninstall_game_logic(meta, &app_handle);
Ok(())
}
fn uninstall_game_logic(meta: DownloadableMetadata, app_handle: &AppHandle) {
println!("Triggered uninstall for agent");
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.applications
.transient_statuses
.entry(meta.clone())
.and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {});
push_game_update(
app_handle,
&meta,
(None, Some(ApplicationTransientStatus::Uninstalling {})),
);
let previous_state = db_handle.applications.game_statuses.get(&meta.id).cloned();
if previous_state.is_none() {
info!("uninstall job doesn't have previous state, failing silently");
return;
}
let previous_state = previous_state.unwrap();
if let Some((version_name, install_dir)) = match previous_state {
GameDownloadStatus::Installed {
version_name,
install_dir,
} => Some((version_name, install_dir)),
GameDownloadStatus::SetupRequired {
version_name,
install_dir,
} => Some((version_name, install_dir)),
_ => None,
} {
db_handle
.applications
.transient_statuses
.entry(meta.clone())
.and_modify(|v| *v = ApplicationTransientStatus::Uninstalling {});
drop(db_handle);
let app_handle = app_handle.clone();
spawn(move || match remove_dir_all(install_dir) {
Err(e) => {
error!("{}", e);
}
Ok(_) => {
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle.applications.transient_statuses.remove(&meta);
db_handle
.applications
.game_statuses
.entry(meta.id.clone())
.and_modify(|e| *e = GameDownloadStatus::Remote {});
drop(db_handle);
DB.save().unwrap();
info!("uninstalled game id {}", &meta.id);
push_game_update(&app_handle, &meta, (Some(GameDownloadStatus::Remote {}), None));
}
});
}
}
pub fn get_current_meta(game_id: &String) -> Result<DownloadableMetadata, String> {
match DB.borrow_data().unwrap().applications.installed_game_version.get(game_id) {
Some(meta) => Ok(meta.clone()),
None => Err(String::from("Could not find installed version")),
}
}
#[tauri::command]
pub fn fetch_game_verion_options<'a>(
game_id: String,
@ -233,19 +325,19 @@ pub fn fetch_game_verion_options<'a>(
}
pub fn on_game_complete(
game_id: String,
version_name: String,
meta: &DownloadableMetadata,
install_dir: String,
app_handle: &AppHandle,
) -> Result<(), RemoteAccessError> {
// Fetch game version information from remote
let base_url = DB.fetch_base_url();
if meta.version.is_none() { return Err(RemoteAccessError::GameNotFound) }
let endpoint = base_url.join(
format!(
"/api/v1/client/metadata/version?id={}&version={}",
game_id,
encode(&version_name)
meta.id,
encode(meta.version.as_ref().unwrap())
)
.as_str(),
)?;
@ -261,38 +353,43 @@ pub fn on_game_complete(
let mut handle = DB.borrow_data_mut().unwrap();
handle
.games
.versions
.entry(game_id.clone())
.applications
.game_versions
.entry(meta.id.clone())
.or_default()
.insert(version_name.clone(), data.clone());
.insert(meta.version.clone().unwrap(), data.clone());
handle
.applications
.installed_game_version
.insert(meta.id.clone(), meta.clone());
drop(handle);
DB.save().unwrap();
let status = if data.setup_command.is_empty() {
GameStatus::Installed {
version_name,
GameDownloadStatus::Installed {
version_name: meta.version.clone().unwrap(),
install_dir,
}
} else {
GameStatus::SetupRequired {
version_name,
GameDownloadStatus::SetupRequired {
version_name: meta.version.clone().unwrap(),
install_dir,
}
};
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.games
.statuses
.insert(game_id.clone(), status.clone());
.applications
.game_statuses
.insert(meta.id.clone(), status.clone());
drop(db_handle);
DB.save().unwrap();
app_handle
.emit(
&format!("update_game/{}", game_id),
&format!("update_game/{}", meta.id),
GameUpdateEvent {
game_id,
game_id: meta.id.clone(),
status: (Some(status), None),
},
)
@ -300,3 +397,15 @@ pub fn on_game_complete(
Ok(())
}
pub fn push_game_update(app_handle: &AppHandle, meta: &DownloadableMetadata, status: GameStatusWithTransient) {
app_handle
.emit(
&format!("update_game/{}", meta.id),
GameUpdateEvent {
game_id: meta.id.clone(),
status,
},
)
.unwrap();
}

View file

@ -0,0 +1,3 @@
pub mod downloads;
pub mod library;
pub mod state;

View file

@ -0,0 +1,32 @@
use crate::{
db::{ApplicationTransientStatus, GameDownloadStatus}, download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata}, fetch_state, DB
};
pub type GameStatusWithTransient = (Option<GameDownloadStatus>, Option<ApplicationTransientStatus>);
pub struct GameStatusManager {}
impl GameStatusManager {
pub fn fetch_state(game_id: &String) -> GameStatusWithTransient {
let db_lock = DB.borrow_data().unwrap();
let online_state = match db_lock.applications.installed_game_version.get(game_id) {
Some(meta) => db_lock
.applications
.transient_statuses
.get(meta)
.cloned(),
None => None,
};
let offline_state = db_lock.applications.game_statuses.get(game_id).cloned();
drop(db_lock);
if online_state.is_some() {
return (None, online_state);
}
if offline_state.is_some() {
return (offline_state, None);
}
(None, None)
}
}

View file

@ -1,46 +1,58 @@
mod auth;
mod db;
mod downloads;
mod library;
mod games;
mod autostart;
mod cleanup;
mod debug;
mod process;
mod remote;
mod state;
mod tools;
pub mod download_manager;
#[cfg(test)]
mod tests;
mod cleanup;
use crate::autostart::{get_autostart_enabled, toggle_autostart};
use crate::db::DatabaseImpls;
use auth::{auth_initiate, generate_authorization_header, recieve_handshake, retry_connect};
use auth::{
auth_initiate, generate_authorization_header, manual_recieve_handshake, recieve_handshake,
retry_connect, sign_out,
};
use cleanup::{cleanup_and_exit, quit};
use db::{
add_download_dir, delete_download_dir, fetch_download_dir_stats, DatabaseInterface,
add_download_dir, delete_download_dir, fetch_download_dir_stats, DatabaseInterface, GameDownloadStatus,
DATA_ROOT_DIR,
};
use downloads::download_commands::*;
use downloads::download_manager::DownloadManager;
use downloads::download_manager_builder::DownloadManagerBuilder;
use debug::fetch_system_data;
use download_manager::download_manager::DownloadManager;
use download_manager::download_manager_builder::DownloadManagerBuilder;
use games::downloads::download_commands::{cancel_game, download_game, move_game_in_queue, pause_game_downloads, resume_game_downloads};
use http::Response;
use http::{header::*, response::Builder as ResponseBuilder};
use library::{fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, Game};
use log::{debug, info, LevelFilter};
use games::library::{
fetch_game, fetch_game_status, fetch_game_verion_options, fetch_library, uninstall_game, Game
};
use log::{debug, info, warn, LevelFilter};
use log4rs::append::console::ConsoleAppender;
use log4rs::append::file::FileAppender;
use log4rs::append::rolling_file::RollingFileAppender;
use log4rs::config::{Appender, Root};
use log4rs::encode::pattern::PatternEncoder;
use log4rs::Config;
use process::process_commands::launch_game;
use process::compat::CompatibilityManager;
use process::process_commands::{kill_game, launch_game};
use process::process_manager::ProcessManager;
use remote::{gen_drop_url, use_remote};
use serde::{Deserialize, Serialize};
use tauri_plugin_dialog::DialogExt;
use std::path::Path;
use std::sync::Arc;
use std::{
collections::HashMap,
sync::{LazyLock, Mutex},
};
use tauri::menu::{Menu, MenuItem, MenuItemBuilder, PredefinedMenuItem};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager, RunEvent, WindowEvent};
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt;
#[derive(Clone, Copy, Serialize)]
@ -65,7 +77,7 @@ pub struct User {
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppState {
pub struct AppState<'a> {
status: AppStatus,
user: Option<User>,
games: HashMap<String, Game>,
@ -73,18 +85,20 @@ pub struct AppState {
#[serde(skip_serializing)]
download_manager: Arc<DownloadManager>,
#[serde(skip_serializing)]
process_manager: Arc<Mutex<ProcessManager>>,
process_manager: Arc<Mutex<ProcessManager<'a>>>,
#[serde(skip_serializing)]
compat_manager: Arc<Mutex<CompatibilityManager>>,
}
#[tauri::command]
fn fetch_state(state: tauri::State<'_, Mutex<AppState>>) -> Result<AppState, String> {
fn fetch_state(state: tauri::State<'_, Mutex<AppState<'_>>>) -> Result<String, String> {
let guard = state.lock().unwrap();
let cloned_state = guard.clone();
let cloned_state = serde_json::to_string(&guard.clone()).map_err(|e| e.to_string())?;
drop(guard);
Ok(cloned_state)
}
fn setup(handle: AppHandle) -> AppState {
fn setup(handle: AppHandle) -> AppState<'static> {
let logfile = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new("{d} | {l} | {f} - {m}{n}")))
.append(false)
@ -110,8 +124,9 @@ fn setup(handle: AppHandle) -> AppState {
log4rs::init_config(config).unwrap();
let games = HashMap::new();
let download_manager = Arc::new(DownloadManagerBuilder::build(handle));
let process_manager = Arc::new(Mutex::new(ProcessManager::new()));
let download_manager = Arc::new(DownloadManagerBuilder::build(handle.clone()));
let process_manager = Arc::new(Mutex::new(ProcessManager::new(handle.clone())));
let compat_manager = Arc::new(Mutex::new(CompatibilityManager::new()));
debug!("Checking if database is set up");
let is_set_up = DB.database_is_set_up();
@ -122,27 +137,80 @@ fn setup(handle: AppHandle) -> AppState {
games,
download_manager,
process_manager,
compat_manager,
};
}
debug!("Database is set up");
let (app_status, user) = auth::setup().unwrap();
let db_handle = DB.borrow_data().unwrap();
let mut missing_games = Vec::new();
let statuses = db_handle.applications.game_statuses.clone();
drop(db_handle);
for (game_id, status) in statuses.into_iter() {
match status {
db::GameDownloadStatus::Remote {} => {}
db::GameDownloadStatus::SetupRequired {
version_name: _,
install_dir,
} => {
let install_dir_path = Path::new(&install_dir);
if !install_dir_path.exists() {
missing_games.push(game_id);
}
}
db::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 = DB.borrow_data_mut().unwrap();
for game_id in missing_games {
db_handle
.applications
.game_statuses
.entry(game_id)
.and_modify(|v| *v = GameDownloadStatus::Remote {});
}
drop(db_handle);
info!("finished setup!");
// Sync autostart state
if let Err(e) = autostart::sync_autostart_on_startup(&handle) {
warn!("Failed to sync autostart state: {}", e);
}
AppState {
status: app_status,
user,
games,
download_manager,
process_manager,
compat_manager,
}
}
pub static DB: LazyLock<DatabaseInterface> = LazyLock::new(DatabaseInterface::set_up_database);
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init());
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init());
#[cfg(desktop)]
#[allow(unused_variables)]
@ -152,15 +220,18 @@ pub fn run() {
}));
}
let mut app = builder
let app = builder
.plugin(tauri_plugin_deep_link::init())
.invoke_handler(tauri::generate_handler![
// Core utils
fetch_state,
quit,
fetch_system_data,
// Auth
auth_initiate,
retry_connect,
manual_recieve_handshake,
sign_out,
// Remote
use_remote,
gen_drop_url,
@ -178,11 +249,19 @@ pub fn run() {
pause_game_downloads,
resume_game_downloads,
cancel_game,
uninstall_game,
// Processes
launch_game,
kill_game,
toggle_autostart,
get_autostart_enabled,
])
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--minimize"]),
))
.setup(|app| {
let handle = app.handle().clone();
let state = setup(handle);
@ -253,6 +332,18 @@ pub fn run() {
.build(app)
.expect("error while setting up tray menu");
{
let mut db_handle = DB.borrow_data_mut().unwrap();
if let Some(original) = db_handle.prev_database.take() {
warn!("Database corrupted. Original file at {}", original.canonicalize().unwrap().to_string_lossy().to_string());
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| {
@ -272,8 +363,16 @@ pub fn run() {
let response = client
.get(object_url.to_string())
.header("Authorization", header)
.send()
.unwrap();
.send();
if response.is_err() {
warn!(
"failed to fetch object with error: {}",
response.err().unwrap()
);
responder.respond(Response::builder().status(500).body(Vec::new()).unwrap());
return;
}
let response = response.unwrap();
let resp_builder = ResponseBuilder::new().header(
CONTENT_TYPE,
@ -284,22 +383,20 @@ pub fn run() {
responder.respond(resp);
})
.on_window_event(|window, event| match event {
WindowEvent::CloseRequested { api, .. } => {
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
window.hide().unwrap();
api.prevent_close();
}
_ => (),
})
.build(tauri::generate_context!())
.expect("error while running tauri application");
app.run(|app_handle, event| match event {
RunEvent::ExitRequested { code, api, .. } => {
app.run(|app_handle, event| {
if let RunEvent::ExitRequested { code, api, .. } = event {
if code.is_none() {
api.prevent_exit();
}
}
_ => {}
});
}

View file

@ -0,0 +1,51 @@
use std::{
fs::create_dir_all,
path::PathBuf,
sync::atomic::{AtomicBool, Ordering},
};
use crate::db::DATA_ROOT_DIR;
pub struct CompatibilityManager {
compat_tools_path: PathBuf,
prefixes_path: PathBuf,
created_paths: AtomicBool,
}
/*
This gets built into both the Windows & Linux client, but
we only need it in the Linux client. Therefore, it should
do nothing but take a little bit of memory if we're on
Windows.
*/
impl CompatibilityManager {
pub fn new() -> Self {
let root_dir_lock = DATA_ROOT_DIR.lock().unwrap();
let compat_tools_path = root_dir_lock.join("compatibility_tools");
let prefixes_path = root_dir_lock.join("prefixes");
drop(root_dir_lock);
Self {
compat_tools_path,
prefixes_path,
created_paths: AtomicBool::new(false),
}
}
fn ensure_paths_exist(&self) -> Result<(), String> {
if self.created_paths.fetch_and(true, Ordering::Relaxed) {
return Ok(());
}
if !self.compat_tools_path.exists() {
create_dir_all(self.compat_tools_path.clone()).map_err(|e| e.to_string())?;
}
if !self.prefixes_path.exists() {
create_dir_all(self.prefixes_path.clone()).map_err(|e| e.to_string())?;
}
self.created_paths.store(true, Ordering::Relaxed);
Ok(())
}
}

View file

@ -1,2 +1,3 @@
pub mod compat;
pub mod process_commands;
pub mod process_manager;
pub mod process_commands;

View file

@ -1,16 +1,40 @@
use std::sync::Mutex;
use crate::AppState;
use crate::{db::GameDownloadStatus, download_manager::downloadable_metadata::{DownloadType, DownloadableMetadata}, games::library::get_current_meta, AppState, DB};
#[tauri::command]
pub fn launch_game(game_id: String, state: tauri::State<'_, Mutex<AppState>>) -> Result<(), String> {
pub fn launch_game(
id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let state_lock = state.lock().unwrap();
let mut process_manager_lock = state_lock.process_manager.lock().unwrap();
process_manager_lock.launch_game(game_id)?;
let version = match DB.borrow_data().unwrap().applications.game_statuses.get(&id).cloned() {
Some(GameDownloadStatus::Installed { version_name, install_dir }) => version_name,
Some(GameDownloadStatus::SetupRequired { version_name, install_dir }) => return Err(String::from("Game setup still required")),
_ => return Err(String::from("Game not installed"))
};
let meta = DownloadableMetadata { id, version: Some(version), download_type: DownloadType::Game };
process_manager_lock.launch_process(meta)?;
drop(process_manager_lock);
drop(state_lock);
Ok(())
}
#[tauri::command]
pub fn kill_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let meta = get_current_meta(&game_id)?;
let state_lock = state.lock().unwrap();
let mut process_manager_lock = state_lock.process_manager.lock().unwrap();
process_manager_lock.kill_game(meta).map_err(|x| x.to_string())
}

View file

@ -1,28 +1,33 @@
use std::{
collections::HashMap,
fs::{File, OpenOptions},
io::{Stdout, Write},
io,
path::{Path, PathBuf},
process::{Child, Command},
sync::LazyLock,
process::{Child, Command, ExitStatus},
sync::{Arc, Mutex},
thread::spawn,
};
use log::info;
use log::{info, warn};
use serde::{Deserialize, Serialize};
use shared_child::SharedChild;
use tauri::{AppHandle, Manager};
use umu_wrapper_lib::command_builder::UmuCommandBuilder;
use crate::{
db::{GameStatus, DATA_ROOT_DIR},
DB,
db::{GameDownloadStatus, ApplicationTransientStatus, DATA_ROOT_DIR}, download_manager::{downloadable::Downloadable, downloadable_metadata::DownloadableMetadata}, games::library::push_game_update, games::state::GameStatusManager, AppState, DB
};
pub struct ProcessManager {
pub struct ProcessManager<'a> {
current_platform: Platform,
log_output_dir: PathBuf,
processes: HashMap<String, Child>,
processes: HashMap<DownloadableMetadata, Arc<SharedChild>>,
app_handle: AppHandle,
game_launchers: HashMap<(Platform, Platform), &'a (dyn ProcessHandler + Sync + Send + 'static)>,
}
impl ProcessManager {
pub fn new() -> Self {
impl ProcessManager<'_> {
pub fn new(app_handle: AppHandle) -> Self {
let root_dir_lock = DATA_ROOT_DIR.lock().unwrap();
let log_output_dir = root_dir_lock.join("logs");
drop(root_dir_lock);
@ -34,119 +39,311 @@ impl ProcessManager {
Platform::Linux
},
app_handle,
processes: HashMap::new(),
log_output_dir,
game_launchers: HashMap::from([
// Current platform to target platform
(
(Platform::Windows, Platform::Windows),
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Linux, Platform::Linux),
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Linux, Platform::Windows),
&UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
]),
}
}
fn process_command(&self, install_dir: &String, raw_command: String) -> (String, Vec<String>) {
let command_components = raw_command.split(" ").collect::<Vec<&str>>();
let root = command_components[0].to_string();
// There's no easy way to distinguish between an executable name with
// spaces and it's arguments.
// I think if we just join the install_dir to whatever the user provides us, we'll be alright
// In future, we should have a separate field for executable name and it's arguments
fn process_command(&self, install_dir: &String, raw_command: String) -> (PathBuf, Vec<String>) {
// let command_components = raw_command.split(" ").collect::<Vec<&str>>();
let root = raw_command;
let install_dir = Path::new(install_dir);
let absolute_exe = install_dir.join(root);
/*
let args = command_components[1..]
.into_iter()
.iter()
.map(|v| v.to_string())
.collect();
(absolute_exe.to_str().unwrap().to_owned(), args)
*/
(absolute_exe, Vec::new())
}
pub fn kill_game(&mut self, meta: DownloadableMetadata) -> Result<(), io::Error> {
return match self.processes.get(&meta) {
Some(child) => {
child.kill()?;
child.wait()?;
Ok(())
},
None => Err(io::Error::new(
io::ErrorKind::NotFound,
"Game ID not running",
)),
};
}
fn on_process_finish(&mut self, meta: DownloadableMetadata, result: Result<ExitStatus, std::io::Error>) {
if !self.processes.contains_key(&meta) {
warn!("process on_finish was called, but game_id is no longer valid. finished with result: {:?}", result);
return;
}
info!("process for {:?} exited with {:?}", meta, result);
self.processes.remove(&meta);
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle.applications.transient_statuses.remove(&meta);
let current_state = db_handle.applications.game_statuses.get(&meta.id).cloned();
if let Some(saved_state) = current_state {
if let GameDownloadStatus::SetupRequired {
version_name,
install_dir,
} = saved_state
{
if let Ok(exit_code) = result {
if exit_code.success() {
db_handle.applications.game_statuses.insert(
meta.id.clone(),
GameDownloadStatus::Installed {
version_name: version_name.to_string(),
install_dir: install_dir.to_string(),
},
);
}
}
}
}
drop(db_handle);
let status = GameStatusManager::fetch_state(&meta.id);
push_game_update(&self.app_handle, &meta, status);
// TODO better management
}
pub fn valid_platform(&self, platform: &Platform) -> Result<bool, String> {
let current = &self.current_platform;
let valid_platforms = PROCESS_COMPATABILITY_MATRIX
.get(current)
.ok_or("Incomplete platform compatability matrix.")?;
Ok(valid_platforms.contains(platform))
Ok(self
.game_launchers
.contains_key(&(current.clone(), platform.clone())))
}
pub fn launch_game(&mut self, game_id: String) -> Result<(), String> {
if self.processes.contains_key(&game_id) {
pub fn launch_process(&mut self, meta: DownloadableMetadata) -> Result<(), String> {
if self.processes.contains_key(&meta) {
return Err("Game or setup is already running.".to_owned());
}
let db_lock = DB.borrow_data().unwrap();
let mut db_lock = DB.borrow_data_mut().unwrap();
info!("Launching process {:?} with games {:?}", meta, db_lock.applications.game_versions);
let game_status = db_lock
.games
.statuses
.get(&game_id)
.applications
.game_statuses
.get(&meta.id)
.ok_or("Game not installed")?;
let GameStatus::Installed {
version_name,
install_dir,
} = game_status
else {
return Err("Game not installed.".to_owned());
let status_metadata: Option<(&String, &String)> = match game_status {
GameDownloadStatus::Installed {
version_name,
install_dir,
} => Some((version_name, install_dir)),
GameDownloadStatus::SetupRequired {
version_name,
install_dir,
} => Some((version_name, install_dir)),
_ => None,
};
if status_metadata.is_none() {
return Err("Game has not been downloaded.".to_owned());
}
let (version_name, install_dir) = status_metadata.unwrap();
let game_version = db_lock
.games
.versions
.get(&game_id)
.applications
.game_versions
.get(&meta.id)
.ok_or("Invalid game ID".to_owned())?
.get(version_name)
.ok_or("Invalid version name".to_owned())?;
let (command, args) =
self.process_command(install_dir, game_version.launch_command.clone());
let raw_command: String = match game_status {
GameDownloadStatus::Installed {
version_name: _,
install_dir: _,
} => game_version.launch_command.clone(),
GameDownloadStatus::SetupRequired {
version_name: _,
install_dir: _,
} => game_version.setup_command.clone(),
_ => panic!("unreachable code"),
};
info!("launching process {} in {}", command, install_dir);
let (command, args) = self.process_command(install_dir, raw_command);
let target_current_dir = command.parent().unwrap().to_str().unwrap();
info!(
"launching process {} in {}",
command.to_str().unwrap(),
target_current_dir
);
let current_time = chrono::offset::Local::now();
let mut log_file = OpenOptions::new()
let log_file = OpenOptions::new()
.write(true)
.truncate(true)
.read(true)
.create(true)
.open(
self.log_output_dir
.join(format!("{}-{}.log", game_id, current_time.timestamp())),
.join(format!("{}-{}-{}.log", meta.id.clone(), meta.version.clone().unwrap_or_default(), current_time.timestamp())),
)
.map_err(|v| v.to_string())?;
let mut error_file = OpenOptions::new()
let error_file = OpenOptions::new()
.write(true)
.truncate(true)
.read(true)
.create(true)
.open(
self.log_output_dir
.join(format!("{}-{}-error.log", game_id, current_time.timestamp())),
)
.open(self.log_output_dir.join(format!(
"{}-{}-{}-error.log",
meta.id.clone(),
meta.version.clone().unwrap_or_default(),
current_time.timestamp()
)))
.map_err(|v| v.to_string())?;
info!("opened log file for {}", command);
let current_platform = self.current_platform.clone();
let target_platform = game_version.platform.clone();
let launch_process = Command::new(command)
.current_dir(install_dir)
.stdout(log_file)
.stderr(error_file)
.args(args)
.spawn()
.map_err(|v| v.to_string())?;
let game_launcher = self
.game_launchers
.get(&(current_platform, target_platform))
.ok_or("Invalid version for this platform.")
.map_err(|e| e.to_string())?;
self.processes.insert(game_id, launch_process);
let launch_process = game_launcher.launch_process(
&meta,
command.to_str().unwrap().to_owned(),
args,
&target_current_dir.to_string(),
log_file,
error_file,
)?;
let launch_process_handle =
Arc::new(SharedChild::new(launch_process).map_err(|e| e.to_string())?);
db_lock
.applications
.transient_statuses
.insert(meta.clone(), ApplicationTransientStatus::Running {});
push_game_update(
&self.app_handle,
&meta,
(None, Some(ApplicationTransientStatus::Running {})),
);
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();
process_manager_handle.on_process_finish(wait_thread_game_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(meta, wait_thread_handle);
info!("finished spawning process");
Ok(())
}
}
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone)]
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Debug)]
pub enum Platform {
Windows,
Linux,
}
pub type ProcessCompatabilityMatrix = HashMap<Platform, Vec<Platform>>;
pub static PROCESS_COMPATABILITY_MATRIX: LazyLock<ProcessCompatabilityMatrix> =
LazyLock::new(|| {
let mut matrix: ProcessCompatabilityMatrix = HashMap::new();
pub trait ProcessHandler: Send + 'static {
fn launch_process(
&self,
meta: &DownloadableMetadata,
command: String,
args: Vec<String>,
current_dir: &String,
log_file: File,
error_file: File,
) -> Result<Child, String>;
}
matrix.insert(Platform::Windows, vec![Platform::Windows]);
matrix.insert(Platform::Linux, vec![Platform::Linux]); // TODO: add Proton support
struct NativeGameLauncher;
impl ProcessHandler for NativeGameLauncher {
fn launch_process(
&self,
meta: &DownloadableMetadata,
command: String,
args: Vec<String>,
current_dir: &String,
log_file: File,
error_file: File,
) -> Result<Child, String> {
Command::new(command)
.current_dir(current_dir)
.stdout(log_file)
.stderr(error_file)
.args(args)
.spawn()
.map_err(|v| v.to_string())
}
}
return matrix;
});
const UMU_LAUNCHER_EXECUTABLE: &str = "umu-run";
struct UMULauncher;
impl ProcessHandler for UMULauncher {
fn launch_process(
&self,
meta: &DownloadableMetadata,
command: String,
args: Vec<String>,
current_dir: &String,
log_file: File,
error_file: File,
) -> Result<Child, String> {
UmuCommandBuilder::new(UMU_LAUNCHER_EXECUTABLE, command)
.game_id(String::from("0"))
.launch_args(args)
.build()
.spawn()
.map_err(|x| x.to_string())
}
}

View file

@ -1,10 +1,12 @@
use std::{
error::Error,
fmt::{Display, Formatter},
sync::{Arc, Mutex},
};
use http::StatusCode;
use log::{info, warn};
use reqwest::blocking::Response;
use serde::Deserialize;
use url::{ParseError, Url};
@ -16,31 +18,44 @@ pub enum RemoteAccessError {
ParsingError(ParseError),
InvalidCodeError(u16),
InvalidEndpoint,
HandshakeFailed,
HandshakeFailed(String),
GameNotFound,
InvalidResponse,
InvalidResponse(DropServerError),
InvalidRedirect,
ManifestDownloadFailed(StatusCode, String),
OutOfSync,
Generic(String),
}
impl Display for RemoteAccessError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
RemoteAccessError::FetchError(error) => write!(f, "{}", error),
RemoteAccessError::FetchError(error) => write!(
f,
"{}: {}",
error,
error
.source()
.map(|e| e.to_string())
.or_else(|| Some("Unknown error".to_string()))
.unwrap()
),
RemoteAccessError::ParsingError(parse_error) => {
write!(f, "{}", parse_error)
}
RemoteAccessError::InvalidCodeError(error) => write!(f, "Invalid HTTP code {}", error),
RemoteAccessError::InvalidEndpoint => write!(f, "Invalid drop endpoint"),
RemoteAccessError::HandshakeFailed => write!(f, "Failed to complete handshake"),
RemoteAccessError::HandshakeFailed(message) => write!(f, "Failed to complete handshake: {}", message),
RemoteAccessError::GameNotFound => write!(f, "Could not find game on server"),
RemoteAccessError::InvalidResponse => write!(f, "Server returned an invalid response"),
RemoteAccessError::InvalidResponse(error) => write!(f, "Server returned an invalid response: {} {}", error.status_code, error.status_message),
RemoteAccessError::InvalidRedirect => write!(f, "Server redirect was invalid"),
RemoteAccessError::ManifestDownloadFailed(status, response) => write!(
f,
"Failed to download game manifest: {} {}",
status, response
),
RemoteAccessError::OutOfSync => write!(f, "Server's and client's time are out of sync. Please ensure they are within at least 30 seconds of each other."),
RemoteAccessError::Generic(message) => write!(f, "{}", message),
}
}
}
@ -63,6 +78,15 @@ impl From<u16> for RemoteAccessError {
impl std::error::Error for RemoteAccessError {}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DropServerError {
pub status_code: usize,
pub status_message: String,
pub message: String,
pub url: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DropHealthcheck {
@ -71,7 +95,7 @@ struct DropHealthcheck {
async fn use_remote_logic<'a>(
url: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'a>>>,
) -> Result<(), RemoteAccessError> {
info!("connecting to url {}", url);
let base_url = Url::parse(&url)?;
@ -103,7 +127,7 @@ async fn use_remote_logic<'a>(
#[tauri::command]
pub async fn use_remote<'a>(
url: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'a>>>,
) -> Result<(), String> {
let result = use_remote_logic(url, state).await;

View file

@ -1,31 +0,0 @@
use std::collections::HashMap;
use crate::{
db::{GameStatus, GameTransientStatus},
DB,
};
pub type GameStatusWithTransient = (
Option<GameStatus>,
Option<GameTransientStatus>,
);
pub struct GameStatusManager {}
impl GameStatusManager {
pub fn fetch_state(game_id: &String) -> GameStatusWithTransient {
let db_lock = DB.borrow_data().unwrap();
let offline_state = db_lock.games.statuses.get(game_id).cloned();
let online_state = db_lock.games.transient_statuses.get(game_id).cloned();
drop(db_lock);
if online_state.is_some() {
return (None, online_state);
}
if offline_state.is_some() {
return (offline_state, None);
}
return (None, None);
}
}

View file

@ -0,0 +1,3 @@
pub struct CompatibilityLayer {
}

View file

@ -0,0 +1,4 @@
mod prefix;
mod registry;
mod tool;
mod compatibility_layer;

View file

View file

@ -0,0 +1,7 @@
use std::collections::HashMap;
use crate::download_manager::downloadable::Downloadable;
pub struct Registry<T: Downloadable> {
tools: HashMap<String, T>
}

View file

@ -0,0 +1,54 @@
use std::sync::Arc;
use tauri::AppHandle;
use crate::download_manager::{application_download_error::ApplicationDownloadError, download_thread_control_flag::DownloadThreadControl, downloadable::Downloadable, downloadable_metadata::DownloadableMetadata, progress_object::ProgressObject};
pub struct ToolDownloadAgent {
id: String,
version: String,
location: String,
control_flag: DownloadThreadControl,
progress: Arc<ProgressObject>,
}
impl Downloadable for ToolDownloadAgent {
fn download(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
todo!()
}
fn progress(&self) -> Arc<ProgressObject> {
todo!()
}
fn control_flag(&self) -> DownloadThreadControl {
todo!()
}
fn status(&self) -> crate::download_manager::download_manager::DownloadStatus {
todo!()
}
fn metadata(&self) -> DownloadableMetadata {
todo!()
}
fn on_initialised(&self, app_handle: &tauri::AppHandle) {
todo!()
}
fn on_error(&self, app_handle: &tauri::AppHandle, error: crate::download_manager::application_download_error::ApplicationDownloadError) {
todo!()
}
fn on_complete(&self, app_handle: &tauri::AppHandle) {
todo!()
}
fn on_incomplete(&self, app_handle: &tauri::AppHandle) {
todo!()
}
fn on_cancelled(&self, app_handle: &tauri::AppHandle) {
todo!()
}
}

View file

@ -40,6 +40,7 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"externalBin": []
}
}

View file

@ -52,9 +52,23 @@ export enum GameStatusEnum {
Updating = "Updating",
Uninstalling = "Uninstalling",
SetupRequired = "SetupRequired",
Running = "Running"
}
export type GameStatus = {
type: GameStatusEnum;
version_name?: string;
};
export enum DownloadableType {
Game = "Game",
Tool = "Tool",
DLC = "DLC",
Mod = "Mod"
}
export type DownloadableMetadata = {
id: string,
version: string,
downloadType: DownloadableType
}

View file

@ -0,0 +1,9 @@
import { type DownloadableMetadata, DownloadableType } from '~/types'
export default function generateGameMeta(gameId: string, version: string): DownloadableMetadata {
return {
id: gameId,
version,
downloadType: DownloadableType.Game
}
}

View file

@ -1392,6 +1392,13 @@
dependencies:
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-os@~2":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.2.0.tgz#ef5511269f59c0ccc580a9d09600034cfaa9743b"
integrity sha512-HszbCdbisMlu5QhCNAN8YIWyz2v33abAWha6+uvV2CKX8P5VSct/y+kEe22JeyqrxCnWlQ3DRx7s49Byg7/0EA==
dependencies:
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-shell@>=2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0.tgz#b6fc88ab070fd5f620e46405715779aa44eb8428"
@ -3928,11 +3935,6 @@ mlly@^1.3.0, mlly@^1.4.2, mlly@^1.6.1, mlly@^1.7.1:
pkg-types "^1.2.0"
ufo "^1.5.4"
moment@^2.30.1:
version "2.30.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
mri@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"