Compare commits

...

16 Commits

Author SHA1 Message Date
ea6fa551a2 chore: Remove all unwraps from util.rs and add state_lock macro
Signed-off-by: quexeky <git@quexeky.dev>
2025-09-04 18:02:36 +10:00
be4fc2d37a fix: Add lint and remove all unwraps from lib.rs
Signed-off-by: quexeky <git@quexeky.dev>
2025-09-04 17:29:52 +10:00
7e70a17a43 Bump version to v0.3.3 2025-08-28 18:23:12 +10:00
8d61a68b8a Add placeholders to unfinished pages (#126)
* feat: add placeholders for community & news pages

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

* fix: remove debug utils

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

* chore: tmp disable non-macos builds

* fix: windows process fix patch

* fix: re-enable windows

* fix: remove sudo

* fix: tmp disable windows again

* fix: windows build again

* chore: re-disable windows

* fix: pin to macos 14

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

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

* fix: game status updates for UI

* fix: missing game version on push_game_update calls

* feat: wait if library load takes <300ms

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

* fix: show installed games not on remote

* fix: more download_logic error handling

* partial: move to async

* feat: interactivity improvements

* feat: v2 download API

* fix: download seek offsets

* fix: clippy

* fix: apply clippy suggestion

* fix: performance improvements starting up download

* fix: finished bucket file

* fix: ui tweaks and fixes

* fix: revert version to 0.3.2

* fix: clippy
2025-08-09 15:50:21 +10:00
3b830e2a44 Move frontend to main folder (#109)
* feat: small refactor

* fix: appimage build script

* fix: add NO_STRIP to AppImage build

* fix: build and dev mode from refactor

* fix: submodule step 1

* fix: submodules step 2
2025-08-05 16:09:47 +10:00
75a4b73ee1 QoL Download Manager (#108)
* feat: retry specific download errors

* fix: potential fix for cmd window on Windows

* feat: add disk space check for download

* fix: update game fix formatting

* fix: clippy
2025-08-04 16:30:45 +10:00
339d707092 Fix errors with caching when cache is deleted (#101) 2025-08-04 15:02:32 +10:00
776dc8fe7a Fixes reqwest client setup, #87 (#107) 2025-08-04 15:01:44 +10:00
120 changed files with 10029 additions and 9054 deletions

View File

@ -51,7 +51,7 @@ jobs:
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' # This must match the platform value defined above.
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
# webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
@ -69,9 +69,9 @@ jobs:
security set-keychain-settings -t 3600 -u build.keychain
curl https://droposs.org/drop.crt --output drop.pem
sudo security authorizationdb write com.apple.trust-settings.admin allow
sudo security add-trusted-cert -d -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem
sudo security authorizationdb remove com.apple.trust-settings.admin
sudo security authorizationdb write com.apple.trust-settings.user allow
security add-trusted-cert -r trustRoot -k build.keychain -p codeSign -u -1 drop.pem
sudo security authorizationdb remove com.apple.trust-settings.user
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
@ -94,10 +94,11 @@ jobs:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
NO_STRIP: true
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
releaseName: 'Auto-release v__VERSION__'
releaseBody: 'See the assets to download this version and install. This release was created automatically.'
releaseDraft: false
prerelease: true
args: ${{ matrix.args }}
args: ${{ matrix.args }}

5
.gitignore vendored
View File

@ -26,4 +26,7 @@ dist-ssr
.output
src-tauri/flamegraph.svg
src-tauri/perf*
src-tauri/perf*
/*.AppImage
/squashfs-root

9
.gitmodules vendored
View File

@ -1,9 +1,6 @@
[submodule "drop-base"]
path = drop-base
url = https://github.com/drop-oss/drop-base
[submodule "src-tauri/tailscale/libtailscale"]
path = src-tauri/tailscale/libtailscale
url = https://github.com/tailscale/libtailscale.git
[submodule "src-tauri/umu/umu-launcher"]
path = src-tauri/umu/umu-launcher
url = https://github.com/Open-Wine-Components/umu-launcher.git
[submodule "libs/drop-base"]
path = libs/drop-base
url = https://github.com/drop-oss/drop-base.git

48
build.mjs Normal file
View File

@ -0,0 +1,48 @@
import fs from "fs";
import process from "process";
import childProcess from "child_process";
import createLogger from "pino";
const OUTPUT = "./.output";
const logger = createLogger({ transport: { target: "pino-pretty" } });
async function spawn(exec, opts) {
const output = childProcess.spawn(exec, { ...opts, shell: true });
output.stdout.on("data", (data) => {
process.stdout.write(data);
});
output.stderr.on("data", (data) => {
process.stderr.write(data);
});
return await new Promise((resolve, reject) => {
output.on("error", (err) => reject(err));
output.on("exit", () => resolve());
});
}
const views = fs.readdirSync(".").filter((view) => {
const expectedPath = `./${view}/package.json`;
return fs.existsSync(expectedPath);
});
fs.mkdirSync(OUTPUT, { recursive: true });
for (const view of views) {
const loggerChild = logger.child({});
process.chdir(`./${view}`);
loggerChild.info(`Install deps for "${view}"`);
await spawn("yarn");
loggerChild.info(`Building "${view}"`);
await spawn("yarn build", {
env: { ...process.env, NUXT_APP_BASE_URL: `/${view}/` },
});
process.chdir("..");
fs.cpSync(`./${view}/.output/public`, `${OUTPUT}/${view}`, {
recursive: true,
});
}

View File

@ -1,3 +0,0 @@
import type { AppState } from "~/types";
export const useAppState = () => useState<AppState>("state");

Submodule drop-base deleted from 26698e5b06

1
libs/drop-base Submodule

Submodule libs/drop-base added at 04125e89be

View File

@ -10,8 +10,6 @@
import "~/composables/downloads.js";
import { invoke } from "@tauri-apps/api/core";
import { AppStatus } from "~/types";
import { listen } from "@tauri-apps/api/event";
import { useAppState } from "./composables/app-state.js";
import {
initialNavigation,
@ -21,19 +19,26 @@ import {
const router = useRouter();
const state = useAppState();
try {
state.value = JSON.parse(await invoke("fetch_state"));
} catch (e) {
console.error("failed to parse state", e);
async function fetchState() {
try {
state.value = JSON.parse(await invoke("fetch_state"));
if (!state.value)
throw createError({
statusCode: 500,
statusMessage: `App state is: ${state.value}`,
fatal: true,
});
} catch (e) {
console.error("failed to parse state", e);
throw e;
}
}
await fetchState();
// This is inefficient but apparently we do it lol
router.beforeEach(async () => {
try {
state.value = JSON.parse(await invoke("fetch_state"));
} catch (e) {
console.error("failed to parse state", e);
}
await fetchState();
});
setupHooks();

View File

Before

Width:  |  Height:  |  Size: 6.5 MiB

After

Width:  |  Height:  |  Size: 6.5 MiB

View File

@ -37,7 +37,7 @@
<component class="h-5" :is="item.icon" />
</HeaderWidget>
</li>
<OfflineHeaderWidget v-if="state.status === AppStatus.Offline" />
<OfflineHeaderWidget v-if="state?.status === AppStatus.Offline" />
<HeaderUserWidget />
</ol>
</div>

View File

@ -1,5 +1,5 @@
<template>
<Menu v-if="state.user" as="div" class="relative inline-block">
<Menu v-if="state?.user" as="div" class="relative inline-block">
<MenuButton>
<HeaderWidget>
<div class="inline-flex items-center text-zinc-300 hover:text-white">
@ -87,7 +87,7 @@ router.afterEach(() => {
const state = useAppState();
const profilePictureUrl: string = await useObject(
state.value.user?.profilePictureObjectId ?? ""
state.value?.user?.profilePictureObjectId ?? ""
);
const adminUrl: string = await invoke("gen_drop_url", {
path: "/admin",

View File

@ -1,72 +1,59 @@
<template>
<div>
<div class="flex flex-col h-full">
<div class="mb-3 inline-flex gap-x-2">
<div
class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
>
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<MagnifyingGlassIcon
class="h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
<div class="relative transition-transform duration-300 hover:scale-105 active:scale-95">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MagnifyingGlassIcon class="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
v-model="searchQuery"
<input type="text" v-model="searchQuery"
class="block w-full rounded-lg border-0 bg-zinc-800/50 py-2 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-500 focus:bg-zinc-800 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
placeholder="Search library..."
/>
placeholder="Search library..." />
</div>
<button
@click="() => calculateGames(true)"
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100"
>
<button @click="() => calculateGames(true)"
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100">
<ArrowPathIcon class="size-4" />
</button>
</div>
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
<NuxtLink
v-for="nav in filteredNavigation"
:key="nav.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center py-2 px-3 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
nav.index === currentNavigation
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: nav.isInstalled.value
<NuxtLink v-for="(nav, navIndex) in filteredNavigation" :key="nav.id" :class="[
'transition-all duration-300 rounded-lg flex items-center py-2 px-3 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
navIndex === currentNavigation
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: nav.isInstalled.value
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
]"
:href="nav.route"
>
]" :href="nav.route">
<div class="flex items-center w-full gap-x-3">
<div
class="flex-none transition-transform duration-300 hover:-rotate-2"
>
<img
class="size-8 object-cover bg-zinc-900 rounded-lg transition-all duration-300 shadow-sm"
:src="icons[nav.id]"
alt=""
/>
<div class="flex-none transition-transform duration-300 hover:-rotate-2">
<img class="size-8 object-cover bg-zinc-900 rounded-lg transition-all duration-300 shadow-sm"
:src="icons[nav.id]" alt="" />
</div>
<div class="flex flex-col flex-1">
<p
class="truncate text-xs font-display leading-5 flex-1 font-semibold"
>
<p class="truncate text-xs font-display leading-5 flex-1 font-semibold">
{{ nav.label }}
</p>
<p
class="text-xs font-medium"
:class="[gameStatusTextStyle[games[nav.id].status.value.type]]"
>
<p class="text-xs font-medium" :class="[gameStatusTextStyle[games[nav.id].status.value.type]]">
{{ gameStatusText[games[nav.id].status.value.type] }}
</p>
</div>
</div>
</NuxtLink>
</TransitionGroup>
<div v-if="loading" class="h-full grow flex p-8 justify-center text-zinc-100">
<div role="status">
<svg aria-hidden="true" class="w-6 h-6 text-transparent animate-spin fill-zinc-600" viewBox="0 0 100 101"
fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor" />
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill" />
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
</template>
@ -80,12 +67,12 @@ import { listen } from "@tauri-apps/api/event";
// Style information
const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Installed]: "text-green-500",
[GameStatusEnum.Downloading]: "text-blue-500",
[GameStatusEnum.Downloading]: "text-zinc-400",
[GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-green-500",
[GameStatusEnum.Remote]: "text-zinc-500",
[GameStatusEnum.Queued]: "text-blue-500",
[GameStatusEnum.Updating]: "text-blue-500",
[GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-zinc-400",
[GameStatusEnum.Uninstalling]: "text-zinc-100",
[GameStatusEnum.SetupRequired]: "text-yellow-500",
[GameStatusEnum.PartiallyInstalled]: "text-gray-400",
@ -107,6 +94,7 @@ const router = useRouter();
const searchQuery = ref("");
const loading = ref(false);
const games: {
[key: string]: { game: Game; status: Ref<GameStatus, GameStatus> };
} = {};
@ -115,7 +103,10 @@ const icons: { [key: string]: string } = {};
const rawGames: Ref<Game[], Game[]> = ref([]);
async function calculateGames(clearAll = false) {
if (clearAll) rawGames.value = [];
if (clearAll) {
rawGames.value = [];
loading.value = true;
}
// If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out
const newGames = await invoke<typeof rawGames.value>("fetch_library");
@ -127,10 +118,22 @@ async function calculateGames(clearAll = false) {
if (icons[game.id]) continue;
icons[game.id] = await useObject(game.mIconObjectId);
}
loading.value = false;
rawGames.value = newGames;
}
await calculateGames();
// Wait up to 300 ms for the library to load, otherwise
// show the loading state while we while
await new Promise<void>((r) => {
let hasResolved = false;
const resolveFunc = () => {
if (!hasResolved) r();
hasResolved = true
}
calculateGames(true).then(resolveFunc);
setTimeout(resolveFunc, 300);
})
const navigation = computed(() =>
rawGames.value.map((game) => {
@ -138,8 +141,7 @@ const navigation = computed(() =>
const isInstalled = computed(
() =>
status.value.type == GameStatusEnum.Installed ||
status.value.type == GameStatusEnum.SetupRequired
status.value.type != GameStatusEnum.Remote
);
const item = {
@ -152,9 +154,11 @@ const navigation = computed(() =>
return item;
})
);
const { currentNavigation, recalculateNavigation } = useCurrentNavigationIndex(
navigation.value
);
const route = useRoute();
const currentNavigation = computed(() => {
return navigation.value.findIndex((e) => e.route == route.path)
});
const filteredNavigation = computed(() => {
if (!searchQuery.value)
@ -169,9 +173,7 @@ listen("update_library", async (event) => {
console.log("Updating library");
let oldNavigation = navigation.value[currentNavigation.value];
await calculateGames();
recalculateNavigation();
if (oldNavigation !== navigation.value[currentNavigation.value]) {
console.log("Triggered");
if (oldNavigation.route !== navigation.value[currentNavigation.value].route) {
router.push("/library");
}
});

View File

@ -0,0 +1,3 @@
import type { AppState } from "~/types";
export const useAppState = () => useState<AppState | undefined>("state");

View File

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

View File

@ -65,7 +65,13 @@ export function setupHooks() {
*/
}
export function initialNavigation(state: Ref<AppState>) {
export function initialNavigation(state: ReturnType<typeof useAppState>) {
if (!state.value)
throw createError({
statusCode: 500,
statusMessage: "App state not valid",
fatal: true,
});
const router = useRouter();
switch (state.value.status) {

View File

@ -7,6 +7,7 @@
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<Logo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"

View File

@ -13,5 +13,9 @@ export default defineNuxtConfig({
ssr: false,
extends: [["./drop-base"]],
extends: [["../libs/drop-base"]],
app: {
baseURL: "/main",
}
});

37
main/package.json Normal file
View File

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

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

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

View File

@ -243,7 +243,10 @@
</div>
</Listbox>
</div>
<div v-else class="mt-1 rounded-md bg-red-600/10 p-4">
<div
v-else-if="versionOptions === null || versionOptions?.length == 0"
class="mt-1 rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
@ -256,6 +259,27 @@
</div>
</div>
</div>
<div v-else class="w-full flex items-center justify-center p-4">
<div role="status">
<svg
aria-hidden="true"
class="w-7 h-7 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-if="installDirs">
<Listbox as="div" v-model="installDir">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100"
@ -510,13 +534,13 @@ async function installFlow() {
installDirs.value = undefined;
try {
versionOptions.value = await invoke("fetch_game_verion_options", {
versionOptions.value = await invoke("fetch_game_version_options", {
gameId: game.value.id,
});
console.log(versionOptions.value);
installDirs.value = await invoke("fetch_download_dir_stats");
} catch (error) {
installError.value = (error as string).toString();
versionOptions.value = undefined;
}
}

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

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

View File

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

8091
main/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

22
optimize-appimage.sh Executable file
View File

@ -0,0 +1,22 @@
## This script is largely useless, because there's not much we can do about AppImage size
ARCH=$(uname -m)
# build tauri apps
# NO_STRIP=true yarn tauri build -- --verbose
# unpack appimage
APPIMAGE=$(ls ./src-tauri/target/release/bundle/appimage/*.AppImage)
"$APPIMAGE" --appimage-extract
# strip binary
APPIMAGE_UNPACK="./squashfs-root"
find $APPIMAGE_UNPACK -type f -exec strip -s {} \;
APPIMAGETOOL=$(echo "obsolete-appimagetool-$ARCH.AppImage")
wget -O $APPIMAGETOOL "https://github.com/AppImage/AppImageKit/releases/download/13/$APPIMAGETOOL"
chmod +x $APPIMAGETOOL
APPIMAGE_OUTPUT=$(./$APPIMAGETOOL $APPIMAGE_UNPACK | grep ".AppImage" | grep squashfs-root | awk '{ print $6 }')
mv $APPIMAGE_OUTPUT "$APPIMAGE"

View File

@ -1,46 +1,22 @@
{
"name": "drop-app",
"private": true,
"version": "0.3.1",
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"build": "node ./build.mjs",
"tauri": "tauri"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@nuxtjs/tailwindcss": "^6.12.2",
"@tauri-apps/api": ">=2.0.0",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/api": "^2.7.0",
"@tauri-apps/plugin-deep-link": "^2.4.1",
"@tauri-apps/plugin-dialog": "^2.3.2",
"@tauri-apps/plugin-opener": "^2.4.0",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-shell": "^2.2.1",
"koa": "^2.16.1",
"markdown-it": "^14.1.0",
"micromark": "^4.0.1",
"nuxt": "^3.16.0",
"scss": "^0.2.4",
"vue": "latest",
"vue-router": "latest",
"vuedraggable": "^4.1.0"
"@tauri-apps/plugin-os": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.3.0",
"pino": "^9.7.0",
"pino-pretty": "^13.1.1"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tauri-apps/cli": ">=2.0.0",
"@types/markdown-it": "^14.1.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"sass-embedded": "^1.79.4",
"tailwindcss": "^3.4.13",
"typescript": "^5.8.3",
"vue-tsc": "^2.2.10"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"@tauri-apps/cli": "^2.7.1"
}
}

View File

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

279
src-tauri/Cargo.lock generated
View File

@ -898,7 +898,7 @@ dependencies = [
"bitflags 2.9.1",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@ -1284,7 +1284,7 @@ dependencies = [
[[package]]
name = "drop-app"
version = "0.3.1"
version = "0.3.3"
dependencies = [
"atomic-instant-full",
"bitcode",
@ -1301,6 +1301,7 @@ dependencies = [
"hex 0.4.3",
"http 1.3.1",
"http-serde 2.1.1",
"humansize",
"known-folders",
"log",
"log4rs",
@ -1311,7 +1312,7 @@ dependencies = [
"rand 0.9.1",
"rayon",
"regex",
"reqwest 0.12.16",
"reqwest 0.12.22",
"reqwest-middleware 0.4.2",
"reqwest-middleware-cache",
"reqwest-websocket",
@ -1324,6 +1325,7 @@ dependencies = [
"sha1",
"shared_child",
"slice-deque",
"sysinfo",
"tar",
"tauri",
"tauri-build",
@ -1586,6 +1588,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@ -1593,7 +1604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@ -1607,6 +1618,12 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@ -2295,6 +2312,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "humantime"
version = "2.2.0"
@ -2355,6 +2381,7 @@ dependencies = [
"hyper 1.6.0",
"hyper-util",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls",
@ -2362,6 +2389,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.6.0",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.13"
@ -2774,9 +2817,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.172"
version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libloading"
@ -2788,6 +2831,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.3"
@ -3050,6 +3099,23 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "native_model"
version = "0.6.1"
@ -3142,6 +3208,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@ -3338,6 +3413,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.1"
@ -3468,6 +3553,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@ -4291,9 +4420,9 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.12.16"
version = "0.12.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf597b113be201cb2269b4c39b39a804d01b99ee95a4278f0ed04e45cff1c71"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
dependencies = [
"base64 0.22.1",
"bytes",
@ -4306,22 +4435,23 @@ dependencies = [
"http-body-util",
"hyper 1.6.0",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
"once_cell",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@ -4360,7 +4490,7 @@ dependencies = [
"anyhow",
"async-trait",
"http 1.3.1",
"reqwest 0.12.16",
"reqwest 0.12.22",
"serde",
"thiserror 1.0.69",
"tower-service",
@ -4395,7 +4525,7 @@ dependencies = [
"async-tungstenite",
"bytes",
"futures-util",
"reqwest 0.12.16",
"reqwest 0.12.22",
"thiserror 2.0.12",
"tokio",
"tokio-util",
@ -4558,6 +4688,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework 3.2.0",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
@ -4600,6 +4742,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "schemars"
version = "0.8.22"
@ -4633,6 +4784,42 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316"
dependencies = [
"bitflags 2.9.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@ -5002,7 +5189,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@ -5190,6 +5377,20 @@ dependencies = [
"libc",
]
[[package]]
name = "sysinfo"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
@ -5302,9 +5503,9 @@ dependencies = [
[[package]]
name = "tauri"
version = "2.6.2"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "124e129c9c0faa6bec792c5948c89e86c90094133b0b9044df0ce5f0a8efaa0d"
checksum = "352a4bc7bf6c25f5624227e3641adf475a6535707451b09bb83271df8b7a6ac7"
dependencies = [
"anyhow",
"bytes",
@ -5329,7 +5530,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.12.16",
"reqwest 0.12.22",
"serde",
"serde_json",
"serde_repr",
@ -5353,9 +5554,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.3.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83"
checksum = "182d688496c06bf08ea896459bf483eb29cdff35c1c4c115fb14053514303064"
dependencies = [
"anyhow",
"cargo_toml",
@ -5375,9 +5576,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.3.0"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df493a1075a241065bc865ed5ef8d0fbc1e76c7afdc0bf0eccfaa7d4f0e406"
checksum = "b54a99a6cd8e01abcfa61508177e6096a4fe2681efecee9214e962f2f073ae4a"
dependencies = [
"base64 0.22.1",
"brotli",
@ -5402,9 +5603,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.3.1"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f237fbea5866fa5f2a60a21bea807a2d6e0379db070d89c3a10ac0f2d4649bbc"
checksum = "7945b14dc45e23532f2ded6e120170bbdd4af5ceaa45784a6b33d250fbce3f9e"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@ -5584,9 +5785,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.7.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e7bb73d1bceac06c20b3f755b2c8a2cb13b20b50083084a8cf3700daf397ba4"
checksum = "2b1cc885be806ea15ff7b0eb47098a7b16323d9228876afda329e34e2d6c4676"
dependencies = [
"cookie",
"dpi",
@ -5606,9 +5807,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.7.1"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "902b5aa9035e16f342eb64f8bf06ccdc2808e411a2525ed1d07672fa4e780bad"
checksum = "fe653a2fbbef19fe898efc774bc52c8742576342a33d3d028c189b57eb1d2439"
dependencies = [
"gtk",
"http 1.3.1",
@ -5633,9 +5834,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.5.0"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41743bbbeb96c3a100d234e5a0b60a46d5aa068f266160862c7afdbf828ca02e"
checksum = "9330c15cabfe1d9f213478c9e8ec2b0c76dab26bb6f314b8ad1c8a568c1d186e"
dependencies = [
"anyhow",
"brotli",
@ -5860,6 +6061,16 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.2"
@ -5964,9 +6175,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.4"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.1",
"bytes",
@ -6271,6 +6482,12 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.0"

View File

@ -1,6 +1,6 @@
[package]
name = "drop-app"
version = "0.3.1"
version = "0.3.3"
description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"]
edition = "2024"
@ -71,6 +71,8 @@ bitcode = "0.6.6"
reqwest-websocket = "0.5.0"
futures-lite = "2.6.0"
page_size = "0.6.0"
sysinfo = "0.36.1"
humansize = "2.1.3"
# tailscale = { path = "./tailscale" }
[dependencies.dynfmt]
@ -78,7 +80,7 @@ version = "0.1.5"
features = ["curly"]
[dependencies.tauri]
version = "2.1.1"
version = "2.7.0"
features = ["protocol-asset", "tray-icon"]
[dependencies.tokio]
@ -102,9 +104,9 @@ version = "2"
features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
[dependencies.reqwest]
version = "0.12"
version = "0.12.22"
default-features = false
features = ["json", "http2", "blocking", "rustls-tls-webpki-roots"]
features = ["json", "http2", "blocking", "rustls-tls", "native-tls-alpn", "rustls-tls-native-roots"]
[dependencies.serde]
version = "1"

View File

@ -266,6 +266,7 @@ pub mod data {
pub install_dirs: Vec<PathBuf>,
// Guaranteed to exist if the game also exists in the app state map
pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>,
pub installed_game_version: HashMap<String, DownloadableMetadata>,

View File

@ -124,11 +124,16 @@ impl DownloadManagerBuilder {
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
*download_thread_lock = None;
if let Some(unfinished_thread) = download_thread_lock.take()
&& !unfinished_thread.is_finished()
{
unfinished_thread.join().unwrap();
}
drop(download_thread_lock);
}
fn stop_and_wait_current_download(&self) {
fn stop_and_wait_current_download(&self) -> bool {
self.set_status(DownloadManagerStatus::Paused);
if let Some(current_flag) = &self.active_control_flag {
current_flag.set(DownloadThreadControlFlag::Stop);
@ -136,8 +141,10 @@ impl DownloadManagerBuilder {
let mut download_thread_lock = self.current_download_thread.lock().unwrap();
if let Some(current_download_thread) = download_thread_lock.take() {
current_download_thread.join().unwrap();
}
return current_download_thread.join().is_ok();
};
true
}
fn manage_queue(mut self) -> Result<(), ()> {
@ -213,10 +220,6 @@ impl DownloadManagerBuilder {
&& self.download_queue.read().front().unwrap()
== &self.current_download_agent.as_ref().unwrap().metadata()
{
debug!(
"Current download agent: {:?}",
self.current_download_agent.as_ref().unwrap().metadata()
);
return;
}
@ -254,12 +257,16 @@ impl DownloadManagerBuilder {
}
};
// If the download gets cancelled
// If the download gets canceled
// immediately return, on_cancelled gets called for us earlier
if !download_result {
return;
}
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
return;
}
let validate_result = match download_agent.validate(&app_handle) {
Ok(v) => v,
Err(e) => {
@ -274,6 +281,10 @@ impl DownloadManagerBuilder {
}
};
if download_agent.control_flag().get() == DownloadThreadControlFlag::Stop {
return;
}
if validate_result {
download_agent.on_complete(&app_handle);
sender
@ -316,6 +327,7 @@ impl DownloadManagerBuilder {
self.stop_and_wait_current_download();
self.remove_and_cleanup_front_download(&current_agent.metadata());
}
self.push_ui_queue_update();
self.set_status(DownloadManagerStatus::Error);
}
fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) {

View File

@ -1,8 +1,8 @@
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
mpsc::Sender,
Arc, Mutex,
},
time::{Duration, Instant},
};
@ -23,9 +23,10 @@ pub struct ProgressObject {
//last_update: Arc<RwLock<Instant>>,
last_update_time: Arc<AtomicInstant>,
bytes_last_update: Arc<AtomicUsize>,
rolling: RollingProgressWindow<250>,
rolling: RollingProgressWindow<1>,
}
#[derive(Clone)]
pub struct ProgressHandle {
progress: Arc<AtomicUsize>,
progress_object: Arc<ProgressObject>,
@ -127,7 +128,7 @@ pub fn calculate_update(progress: &ProgressObject) {
.bytes_last_update
.swap(current_bytes_downloaded, Ordering::Acquire);
let bytes_since_last_update = current_bytes_downloaded - bytes_at_last_update;
let bytes_since_last_update = current_bytes_downloaded.saturating_sub(bytes_at_last_update);
let kilobytes_per_second = bytes_since_last_update / (time_since_last_update as usize).max(1);

View File

@ -1,30 +1,49 @@
use std::{
fmt::{Display, Formatter},
io,
io, sync::Arc,
};
use serde_with::SerializeDisplay;
use humansize::{format_size, BINARY};
use super::remote_access_error::RemoteAccessError;
// TODO: Rename / separate from downloads
#[derive(Debug, SerializeDisplay)]
pub enum ApplicationDownloadError {
NotInitialized,
Communication(RemoteAccessError),
DiskFull(u64, u64),
#[allow(dead_code)]
Checksum,
Lock,
IoError(io::ErrorKind),
IoError(Arc<io::Error>),
DownloadError,
}
impl Display for ApplicationDownloadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ApplicationDownloadError::NotInitialized => write!(f, "Download not initalized, did something go wrong?"),
ApplicationDownloadError::DiskFull(required, available) => write!(
f,
"Game requires {}, {} remaining left on disk.",
format_size(*required, BINARY),
format_size(*available, BINARY),
),
ApplicationDownloadError::Communication(error) => write!(f, "{error}"),
ApplicationDownloadError::Lock => write!(f, "failed to acquire lock. Something has gone very wrong internally. Please restart the application"),
ApplicationDownloadError::Checksum => write!(f, "checksum failed to validate for download"),
ApplicationDownloadError::Lock => write!(
f,
"failed to acquire lock. Something has gone very wrong internally. Please restart the application"
),
ApplicationDownloadError::Checksum => {
write!(f, "checksum failed to validate for download")
}
ApplicationDownloadError::IoError(error) => write!(f, "io error: {error}"),
ApplicationDownloadError::DownloadError => write!(f, "download failed. See Download Manager status for specific error"),
ApplicationDownloadError::DownloadError => write!(
f,
"Download failed. See Download Manager status for specific error"
),
}
}
}

View File

@ -23,6 +23,7 @@ pub enum RemoteAccessError {
ManifestDownloadFailed(StatusCode, String),
OutOfSync,
Cache(std::io::Error),
CorruptedState,
}
impl Display for RemoteAccessError {
@ -81,6 +82,10 @@ impl Display for RemoteAccessError {
"server's and client's time are out of sync. Please ensure they are within at least 30 seconds of each other"
),
RemoteAccessError::Cache(error) => write!(f, "Cache Error: {error}"),
RemoteAccessError::CorruptedState => write!(
f,
"Drop encountered a corrupted internal state. Please report this to the developers, with details of reproduction."
),
}
}
}

View File

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

View File

@ -18,28 +18,28 @@ use crate::{
use super::{
library::{
FetchGameStruct, Game, fetch_game_logic, fetch_game_verion_options_logic,
FetchGameStruct, Game, fetch_game_logic, fetch_game_version_options_logic,
fetch_library_logic,
},
state::{GameStatusManager, GameStatusWithTransient},
};
#[tauri::command]
pub fn fetch_library(
state: tauri::State<'_, Mutex<AppState>>,
pub async fn fetch_library(
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<Vec<Game>, RemoteAccessError> {
offline!(
state,
fetch_library_logic,
fetch_library_logic_offline,
state
)
).await
}
#[tauri::command]
pub fn fetch_game(
pub async fn fetch_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<FetchGameStruct, RemoteAccessError> {
offline!(
state,
@ -47,7 +47,7 @@ pub fn fetch_game(
fetch_game_logic_offline,
game_id,
state
)
).await
}
#[tauri::command]
@ -68,9 +68,9 @@ pub fn uninstall_game(game_id: String, app_handle: AppHandle) -> Result<(), Libr
}
#[tauri::command]
pub fn fetch_game_verion_options(
pub async fn fetch_game_version_options(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<Vec<GameVersion>, RemoteAccessError> {
fetch_game_verion_options_logic(game_id, state)
fetch_game_version_options_logic(game_id, state).await
}

View File

@ -3,43 +3,48 @@ use std::{
sync::{Arc, Mutex},
};
use crate::{
database::{db::borrow_db_checked, models::data::GameDownloadStatus},
download_manager::{
download_manager_frontend::DownloadManagerSignal, downloadable::Downloadable,
},
error::download_manager_error::DownloadManagerError,
AppState,
database::{
db::borrow_db_checked,
models::data::GameDownloadStatus,
},
download_manager::downloadable::Downloadable,
error::application_download_error::ApplicationDownloadError,
};
use super::download_agent::GameDownloadAgent;
#[tauri::command]
pub fn download_game(
pub async fn download_game(
game_id: String,
game_version: String,
install_dir: usize,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), DownloadManagerError<DownloadManagerSignal>> {
let sender = state.lock().unwrap().download_manager.get_sender();
let game_download_agent = Arc::new(Box::new(GameDownloadAgent::new_from_index(
game_id,
game_version,
install_dir,
sender,
)) as Box<dyn Downloadable + Send + Sync>);
Ok(state
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<(), ApplicationDownloadError> {
let sender = { state.lock().unwrap().download_manager.get_sender().clone() };
let game_download_agent =
GameDownloadAgent::new_from_index(game_id.clone(), game_version.clone(), install_dir, sender).await?;
let game_download_agent =
Arc::new(Box::new(game_download_agent) as Box<dyn Downloadable + Send + Sync>);
state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent)?)
.queue_download(game_download_agent.clone())
.unwrap();
Ok(())
}
#[tauri::command]
pub fn resume_download(
pub async fn resume_download(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), DownloadManagerError<DownloadManagerSignal>> {
state: tauri::State<'_, Mutex<AppState<'_>>>,
) -> Result<(), ApplicationDownloadError> {
let s = borrow_db_checked()
.applications
.game_statuses
@ -56,17 +61,25 @@ pub fn resume_download(
install_dir,
} => (version_name, install_dir),
};
let sender = state.lock().unwrap().download_manager.get_sender();
let parent_dir: PathBuf = install_dir.into();
let game_download_agent = Arc::new(Box::new(GameDownloadAgent::new(
game_id,
version_name.clone(),
parent_dir.parent().unwrap().to_path_buf(),
sender,
)) as Box<dyn Downloadable + Send + Sync>);
Ok(state
let game_download_agent = Arc::new(Box::new(
GameDownloadAgent::new(
game_id,
version_name.clone(),
parent_dir.parent().unwrap().to_path_buf(),
sender,
)
.await?,
) as Box<dyn Downloadable + Send + Sync>);
state
.lock()
.unwrap()
.download_manager
.queue_download(game_download_agent)?)
.queue_download(game_download_agent)
.unwrap();
Ok(())
}

View File

@ -11,18 +11,19 @@ use crate::download_manager::util::download_thread_control_flag::{
use crate::download_manager::util::progress_object::{ProgressHandle, ProgressObject};
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::games::downloads::validate::validate_game_chunk;
use crate::games::library::{
on_game_complete, push_game_update, set_partially_installed,
use crate::games::downloads::manifest::{
DownloadBucket, DownloadContext, DownloadDrop, DropManifest, DropValidateContext, ManifestBody,
};
use crate::games::downloads::validate::validate_game_chunk;
use crate::games::library::{on_game_complete, push_game_update, set_partially_installed};
use crate::games::state::GameStatusManager;
use crate::remote::requests::make_request;
use crate::remote::utils::DROP_CLIENT_SYNC;
use log::{debug, error, info};
use crate::process::utils::get_disk_available;
use crate::remote::requests::generate_url;
use crate::remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC};
use log::{debug, error, info, warn};
use rayon::ThreadPoolBuilder;
use std::collections::HashMap;
use std::fs::{OpenOptions, create_dir_all};
use std::collections::{HashMap, HashSet};
use std::fs::{create_dir_all, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
@ -32,14 +33,18 @@ use tauri::{AppHandle, Emitter};
#[cfg(target_os = "linux")]
use rustix::fs::{FallocateFlags, fallocate};
use super::download_logic::download_game_chunk;
use super::download_logic::download_game_bucket;
use super::drop_data::DropData;
static RETRY_COUNT: usize = 3;
const TARGET_BUCKET_SIZE: usize = 63 * 1000 * 1000;
pub struct GameDownloadAgent {
pub id: String,
pub version: String,
pub control_flag: DownloadThreadControl,
contexts: Mutex<Vec<DropDownloadContext>>,
buckets: Mutex<Vec<DownloadBucket>>,
context_map: Mutex<HashMap<String, bool>>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
@ -49,24 +54,26 @@ pub struct GameDownloadAgent {
}
impl GameDownloadAgent {
pub fn new_from_index(
pub async fn new_from_index(
id: String,
version: String,
target_download_dir: usize,
sender: Sender<DownloadManagerSignal>,
) -> Self {
let db_lock = borrow_db_checked();
let base_dir = db_lock.applications.install_dirs[target_download_dir].clone();
drop(db_lock);
) -> Result<Self, ApplicationDownloadError> {
let base_dir = {
let db_lock = borrow_db_checked();
Self::new(id, version, base_dir, sender)
db_lock.applications.install_dirs[target_download_dir].clone()
};
Self::new(id, version, base_dir, sender).await
}
pub fn new(
pub async fn new(
id: String,
version: String,
base_dir: PathBuf,
sender: Sender<DownloadManagerSignal>,
) -> Self {
) -> Result<Self, ApplicationDownloadError> {
// Don't run by default
let control_flag = DownloadThreadControl::new(DownloadThreadControlFlag::Stop);
@ -76,42 +83,64 @@ impl GameDownloadAgent {
let stored_manifest =
DropData::generate(id.clone(), version.clone(), data_base_dir_path.clone());
Self {
let result = Self {
id,
version,
control_flag,
manifest: Mutex::new(None),
contexts: Mutex::new(Vec::new()),
buckets: Mutex::new(Vec::new()),
context_map: Mutex::new(HashMap::new()),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
dropdata: stored_manifest,
status: Mutex::new(DownloadStatus::Queued),
};
result.ensure_manifest_exists().await?;
let required_space = result
.manifest
.lock()
.unwrap()
.as_ref()
.unwrap()
.values()
.map(|e| e.lengths.iter().sum::<usize>())
.sum::<usize>() as u64;
let available_space = get_disk_available(data_base_dir_path)? as u64;
if required_space > available_space {
return Err(ApplicationDownloadError::DiskFull(
required_space,
available_space,
));
}
Ok(result)
}
// Blocking
pub fn setup_download(&self, app_handle: &AppHandle) -> Result<(), ApplicationDownloadError> {
self.ensure_manifest_exists()?;
let mut db_lock = borrow_db_mut_checked();
let status = ApplicationTransientStatus::Downloading {
version_name: self.version.clone(),
};
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
// Don't use GameStatusManager because this game isn't installed
push_game_update(app_handle, &self.metadata().id, None, (None, Some(status)));
self.ensure_contexts()?;
if !self.check_manifest_exists() {
return Err(ApplicationDownloadError::NotInitialized);
}
self.ensure_buckets()?;
self.control_flag.set(DownloadThreadControlFlag::Go);
let mut db_lock = borrow_db_mut_checked();
db_lock.applications.transient_statuses.insert(
self.metadata(),
ApplicationTransientStatus::Downloading {
version_name: self.version.clone(),
},
);
push_game_update(
app_handle,
&self.metadata().id,
None,
GameStatusManager::fetch_state(&self.metadata().id, &db_lock),
);
Ok(())
}
@ -122,9 +151,7 @@ impl GameDownloadAgent {
info!("beginning download for {}...", self.metadata().id);
let res = self
.run()
.map_err(|()| ApplicationDownloadError::DownloadError);
let res = self.run().map_err(ApplicationDownloadError::Communication);
debug!(
"{} took {}ms to download",
@ -134,37 +161,43 @@ impl GameDownloadAgent {
res
}
pub fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
pub fn check_manifest_exists(&self) -> bool {
self.manifest.lock().unwrap().is_some()
}
pub async fn ensure_manifest_exists(&self) -> Result<(), ApplicationDownloadError> {
if self.manifest.lock().unwrap().is_some() {
return Ok(());
}
self.download_manifest()
self.download_manifest().await
}
fn download_manifest(&self) -> Result<(), ApplicationDownloadError> {
let header = generate_authorization_header();
let client = DROP_CLIENT_SYNC.clone();
let response = make_request(
&client,
async fn download_manifest(&self) -> Result<(), ApplicationDownloadError> {
let client = DROP_CLIENT_ASYNC.clone();
let url = generate_url(
&["/api/v1/client/game/manifest"],
&[("id", &self.id), ("version", &self.version)],
|f| f.header("Authorization", header),
)
.map_err(ApplicationDownloadError::Communication)?
.send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
.map_err(ApplicationDownloadError::Communication)?;
let response = client
.get(url)
.header("Authorization", generate_authorization_header())
.send()
.await
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::ManifestDownloadFailed(
response.status(),
response.text().unwrap(),
response.text().await.unwrap(),
),
));
}
let manifest_download: DropManifest = response.json().unwrap();
let manifest_download: DropManifest = response.json().await.unwrap();
if let Ok(mut manifest) = self.manifest.lock() {
*manifest = Some(manifest_download);
@ -176,20 +209,23 @@ impl GameDownloadAgent {
// Sets it up for both download and validate
fn setup_progress(&self) {
let contexts = self.contexts.lock().unwrap();
let buckets = self.buckets.lock().unwrap();
let length = contexts.len();
let chunk_count = buckets.iter().map(|e| e.drops.len()).sum();
let chunk_count = contexts.iter().map(|chunk| chunk.length).sum();
let total_length = buckets
.iter()
.map(|bucket| bucket.drops.iter().map(|e| e.length).sum::<usize>())
.sum();
self.progress.set_max(chunk_count);
self.progress.set_size(length);
self.progress.set_max(total_length);
self.progress.set_size(chunk_count);
self.progress.reset();
}
pub fn ensure_contexts(&self) -> Result<(), ApplicationDownloadError> {
if self.contexts.lock().unwrap().is_empty() {
self.generate_contexts()?;
pub fn ensure_buckets(&self) -> Result<(), ApplicationDownloadError> {
if self.buckets.lock().unwrap().is_empty() {
self.generate_buckets()?;
}
*self.context_map.lock().unwrap() = self.dropdata.get_contexts();
@ -197,14 +233,18 @@ impl GameDownloadAgent {
Ok(())
}
pub fn generate_contexts(&self) -> Result<(), ApplicationDownloadError> {
pub fn generate_buckets(&self) -> Result<(), ApplicationDownloadError> {
let manifest = self.manifest.lock().unwrap().clone().unwrap();
let game_id = self.id.clone();
let mut contexts = Vec::new();
let base_path = Path::new(&self.dropdata.base_path);
create_dir_all(base_path).unwrap();
let mut buckets = Vec::new();
let mut current_buckets = HashMap::<String, DownloadBucket>::new();
let mut current_bucket_sizes = HashMap::<String, usize>::new();
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
@ -219,42 +259,94 @@ impl GameDownloadAgent {
.truncate(false)
.open(path.clone())
.unwrap();
let mut running_offset = 0;
let mut file_running_offset = 0;
for (index, length) in chunk.lengths.iter().enumerate() {
contexts.push(DropDownloadContext {
file_name: raw_path.to_string(),
version: chunk.version_name.to_string(),
offset: running_offset,
index,
game_id: game_id.to_string(),
path: path.clone(),
checksum: chunk.checksums[index].clone(),
let drop = DownloadDrop {
filename: raw_path.to_string(),
start: file_running_offset,
length: *length,
checksum: chunk.checksums[index].clone(),
permissions: chunk.permissions,
});
running_offset += *length as u64;
path: path.clone(),
index,
};
file_running_offset += *length;
if *length >= TARGET_BUCKET_SIZE {
// They get their own bucket
buckets.push(DownloadBucket {
game_id: game_id.clone(),
version: chunk.version_name.clone(),
drops: vec![drop],
});
continue;
}
let current_bucket_size = current_bucket_sizes
.entry(chunk.version_name.clone())
.or_insert_with(|| 0);
let c_version_name = chunk.version_name.clone();
let c_game_id = game_id.clone();
let current_bucket = current_buckets
.entry(chunk.version_name.clone())
.or_insert_with(|| DownloadBucket {
game_id: c_game_id,
version: c_version_name,
drops: vec![],
});
if *current_bucket_size + length >= TARGET_BUCKET_SIZE
&& !current_bucket.drops.is_empty()
{
// Move current bucket into list and make a new one
buckets.push(current_bucket.clone());
*current_bucket = DownloadBucket {
game_id: game_id.clone(),
version: chunk.version_name.clone(),
drops: vec![],
};
*current_bucket_size = 0;
}
current_bucket.drops.push(drop);
*current_bucket_size += *length;
}
#[cfg(target_os = "linux")]
if running_offset > 0 && !already_exists {
let _ = fallocate(file, FallocateFlags::empty(), 0, running_offset);
if file_running_offset > 0 && !already_exists {
let _ = fallocate(file, FallocateFlags::empty(), 0, file_running_offset as u64);
}
}
let existing_contexts = self.dropdata.get_completed_contexts();
for (_, bucket) in current_buckets.into_iter() {
if !bucket.drops.is_empty() {
buckets.push(bucket);
}
}
info!("buckets: {}", buckets.len());
let existing_contexts = self.dropdata.get_contexts();
self.dropdata.set_contexts(
&contexts
&buckets
.iter()
.map(|x| (x.checksum.clone(), existing_contexts.contains(&x.checksum)))
.flat_map(|x| x.drops.iter().map(|v| v.checksum.clone()))
.map(|x| {
let contains = existing_contexts.get(&x).unwrap_or(&false);
(x, *contains)
})
.collect::<Vec<(String, bool)>>(),
);
*self.contexts.lock().unwrap() = contexts;
*self.buckets.lock().unwrap() = buckets;
Ok(())
}
fn run(&self) -> Result<bool, ()> {
fn run(&self) -> Result<bool, RemoteAccessError> {
self.setup_progress();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
@ -267,63 +359,115 @@ impl GameDownloadAgent {
.build()
.unwrap();
let buckets = self.buckets.lock().unwrap();
let mut download_contexts = HashMap::<String, DownloadContext>::new();
let versions = buckets
.iter()
.map(|e| &e.version)
.collect::<HashSet<_>>()
.into_iter().cloned()
.collect::<Vec<String>>();
info!("downloading across these versions: {versions:?}");
let completed_contexts = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_contexts.clone();
let contexts = self.contexts.lock().unwrap();
for version in versions {
let download_context = DROP_CLIENT_SYNC
.post(generate_url(&["/api/v2/client/context"], &[]).unwrap())
.json(&ManifestBody {
game: self.id.clone(),
version: version.clone(),
})
.header("Authorization", generate_authorization_header())
.send()?;
if download_context.status() != 200 {
return Err(RemoteAccessError::InvalidResponse(download_context.json()?));
}
let download_context = download_context.json::<DownloadContext>()?;
info!(
"download context: ({}) {}",
&version, download_context.context
);
download_contexts.insert(version, download_context);
}
let download_contexts = &download_contexts;
pool.scope(|scope| {
let client = &DROP_CLIENT_SYNC.clone();
let context_map = self.context_map.lock().unwrap();
for (index, context) in contexts.iter().enumerate() {
let client = client.clone();
let completed_indexes = completed_indexes_loop_arc.clone();
for (index, bucket) in buckets.iter().enumerate() {
let mut bucket = (*bucket).clone();
let completed_contexts = completed_indexes_loop_arc.clone();
let progress = self.progress.get(index);
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
// If we've done this one already, skip it
// Note to future DecDuck, DropData gets loaded into context_map
if let Some(v) = context_map.get(&context.checksum)
&& *v
{
progress_handle.skip(context.length);
let todo_drops = bucket
.drops
.into_iter()
.filter(|e| {
let todo = !*context_map.get(&e.checksum).unwrap_or(&false);
if !todo {
progress_handle.skip(e.length);
}
todo
})
.collect::<Vec<DownloadDrop>>();
if todo_drops.is_empty() {
continue;
}
};
bucket.drops = todo_drops;
let sender = self.sender.clone();
let request = match make_request(
&client,
&["/api/v1/client/chunk"],
&[
("id", &context.game_id),
("version", &context.version),
("name", &context.file_name),
("chunk", &context.index.to_string()),
],
|r| r,
) {
Ok(request) => request,
Err(e) => {
sender
.send(DownloadManagerSignal::Error(
ApplicationDownloadError::Communication(e),
))
.unwrap();
continue;
}
};
let download_context = download_contexts
.get(&bucket.version)
.ok_or(RemoteAccessError::CorruptedState)
.unwrap();
scope.spawn(move |_| {
match download_game_chunk(context, &self.control_flag, progress_handle, request)
{
Ok(true) => {
completed_indexes.push(context.checksum.clone());
}
Ok(false) => {}
Err(e) => {
error!("{e}");
sender.send(DownloadManagerSignal::Error(e)).unwrap();
// 3 attempts
for i in 0..RETRY_COUNT {
let loop_progress_handle = progress_handle.clone();
match download_game_bucket(
&bucket,
download_context,
&self.control_flag,
loop_progress_handle,
) {
Ok(true) => {
for drop in bucket.drops {
completed_contexts.push(drop.checksum);
}
return;
}
Ok(false) => return,
Err(e) => {
warn!("game download agent error: {e}");
let retry = matches!(
&e,
ApplicationDownloadError::Communication(_)
| ApplicationDownloadError::Checksum
| ApplicationDownloadError::Lock
);
if i == RETRY_COUNT - 1 || !retry {
warn!("retry logic failed, not re-attempting.");
sender.send(DownloadManagerSignal::Error(e)).unwrap();
return;
}
}
}
}
});
@ -340,14 +484,14 @@ impl GameDownloadAgent {
context_map_lock.values().filter(|x| **x).count()
};
let context_map_lock = self.context_map.lock().unwrap();
let contexts = contexts
let contexts = buckets
.iter()
.flat_map(|x| x.drops.iter().map(|e| e.checksum.clone()))
.map(|x| {
(
x.checksum.clone(),
context_map_lock.get(&x.checksum).copied().unwrap_or(false),
)
let completed = context_map_lock.get(&x).unwrap_or(&false);
(x, *completed)
})
.collect::<Vec<(String, bool)>>();
drop(context_map_lock);
@ -358,10 +502,11 @@ impl GameDownloadAgent {
// If there are any contexts left which are false
if !contexts.iter().all(|x| x.1) {
info!(
"download agent for {} exited without completing ({}/{})",
"download agent for {} exited without completing ({}/{}) ({} buckets)",
self.id.clone(),
completed_lock_len,
contexts.len(),
buckets.len()
);
return Ok(false);
}
@ -374,31 +519,30 @@ impl GameDownloadAgent {
self.control_flag.set(DownloadThreadControlFlag::Go);
let status = ApplicationTransientStatus::Validating {
version_name: self.version.clone(),
};
let mut db_lock = borrow_db_mut_checked();
db_lock.applications.transient_statuses.insert(
self.metadata(),
ApplicationTransientStatus::Validating {
version_name: self.version.clone(),
},
);
push_game_update(
app_handle,
&self.metadata().id,
None,
GameStatusManager::fetch_state(&self.metadata().id, &db_lock),
);
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
push_game_update(app_handle, &self.metadata().id, None, (None, Some(status)));
}
pub fn validate(&self, app_handle: &AppHandle) -> Result<bool, ApplicationDownloadError> {
self.setup_validate(app_handle);
let contexts = self.contexts.lock().unwrap();
let buckets = self.buckets.lock().unwrap();
let contexts: Vec<DropValidateContext> = buckets
.clone()
.into_iter()
.flat_map(|e| -> Vec<DropValidateContext> { e.into() })
.collect();
let max_download_threads = borrow_db_checked().settings.max_download_threads;
debug!(
"validating game: {} with {} threads",
self.dropdata.game_id, max_download_threads
);
info!("{} validation contexts", contexts.len());
let pool = ThreadPoolBuilder::new()
.num_threads(max_download_threads)
.build()
@ -452,8 +596,6 @@ impl GameDownloadAgent {
);
self.dropdata.write();
}
}
@ -501,6 +643,13 @@ impl Downloadable for GameDownloadAgent {
.applications
.transient_statuses
.remove(&self.metadata());
push_game_update(
app_handle,
&self.id,
None,
GameStatusManager::fetch_state(&self.id, &handle),
);
}
fn on_complete(&self, app_handle: &tauri::AppHandle) {

View File

@ -5,37 +5,48 @@ use crate::download_manager::util::progress_object::ProgressHandle;
use crate::error::application_download_error::ApplicationDownloadError;
use crate::error::drop_server_error::DropServerError;
use crate::error::remote_access_error::RemoteAccessError;
use crate::games::downloads::manifest::DropDownloadContext;
use crate::games::downloads::manifest::{ChunkBody, DownloadBucket, DownloadContext, DownloadDrop};
use crate::remote::auth::generate_authorization_header;
use log::{debug, warn};
use crate::remote::requests::generate_url;
use crate::remote::utils::DROP_CLIENT_SYNC;
use log::{info, warn};
use md5::{Context, Digest};
use reqwest::blocking::{RequestBuilder, Response};
use reqwest::blocking::Response;
use std::fs::{set_permissions, Permissions};
use std::fs::{Permissions, set_permissions};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write},
path::PathBuf,
};
static MAX_PACKET_LENGTH: usize = 4096 * 4;
pub struct DropWriter<W: Write> {
hasher: Context,
destination: W,
destination: BufWriter<W>,
progress: ProgressHandle,
}
impl DropWriter<File> {
fn new(path: PathBuf) -> Self {
let destination = OpenOptions::new().write(true).create(true).truncate(false).open(&path).unwrap();
Self {
destination,
fn new(path: PathBuf, progress: ProgressHandle) -> Result<Self, io::Error> {
let destination = OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&path)?;
Ok(Self {
destination: BufWriter::with_capacity(1024 * 1024, destination),
hasher: Context::new(),
}
progress,
})
}
fn finish(mut self) -> io::Result<Digest> {
self.flush().unwrap();
self.flush()?;
Ok(self.hasher.compute())
}
}
@ -45,7 +56,10 @@ impl Write for DropWriter<File> {
self.hasher
.write_all(buf)
.map_err(|e| io::Error::other(format!("Unable to write to hasher: {e}")))?;
self.destination.write(buf)
let bytes_written = self.destination.write(buf)?;
self.progress.add(bytes_written);
Ok(bytes_written)
}
fn flush(&mut self) -> io::Result<()> {
@ -62,91 +76,103 @@ impl Seek for DropWriter<File> {
pub struct DropDownloadPipeline<'a, R: Read, W: Write> {
pub source: R,
pub destination: DropWriter<W>,
pub drops: Vec<DownloadDrop>,
pub destination: Vec<DropWriter<W>>,
pub control_flag: &'a DownloadThreadControl,
pub progress: ProgressHandle,
pub size: usize,
}
impl<'a> DropDownloadPipeline<'a, Response, File> {
fn new(
source: Response,
destination: DropWriter<File>,
drops: Vec<DownloadDrop>,
control_flag: &'a DownloadThreadControl,
progress: ProgressHandle,
size: usize,
) -> Self {
Self {
) -> Result<Self, io::Error> {
Ok(Self {
source,
destination,
destination: drops
.iter()
.map(|drop| DropWriter::new(drop.path.clone(), progress.clone()))
.try_collect()?,
drops,
control_flag,
progress,
size,
}
})
}
fn copy(&mut self) -> Result<bool, io::Error> {
let copy_buf_size = 512;
let mut copy_buf = vec![0; copy_buf_size];
let mut buf_writer = BufWriter::with_capacity(1024 * 1024, &mut self.destination);
let mut copy_buffer = [0u8; MAX_PACKET_LENGTH];
for (index, drop) in self.drops.iter().enumerate() {
let destination = self
.destination
.get_mut(index)
.ok_or(io::Error::other("no destination"))
.unwrap();
let mut remaining = drop.length;
if drop.start != 0 {
destination.seek(SeekFrom::Start(drop.start.try_into().unwrap()))?;
}
loop {
let size = MAX_PACKET_LENGTH.min(remaining);
self.source.read_exact(&mut copy_buffer[0..size])?;
remaining -= size;
destination.write_all(&copy_buffer[0..size])?;
if remaining == 0 {
break;
};
}
let mut current_size = 0;
loop {
if self.control_flag.get() == DownloadThreadControlFlag::Stop {
buf_writer.flush()?;
return Ok(false);
}
let mut bytes_read = self.source.read(&mut copy_buf)?;
current_size += bytes_read;
if current_size > self.size {
let over = current_size - self.size;
warn!("server sent too many bytes... {over} over");
bytes_read -= over;
current_size = self.size;
}
buf_writer.write_all(&copy_buf[0..bytes_read])?;
self.progress.add(bytes_read);
if current_size >= self.size {
debug!(
"finished with final size of {} vs {}",
current_size, self.size
);
break;
}
}
buf_writer.flush()?;
Ok(true)
}
fn finish(self) -> Result<Digest, io::Error> {
let checksum = self.destination.finish()?;
Ok(checksum)
fn finish(self) -> Result<Vec<Digest>, io::Error> {
let checksums = self
.destination
.into_iter()
.map(|e| e.finish())
.try_collect()?;
Ok(checksums)
}
}
pub fn download_game_chunk(
ctx: &DropDownloadContext,
pub fn download_game_bucket(
bucket: &DownloadBucket,
ctx: &DownloadContext,
control_flag: &DownloadThreadControl,
progress: ProgressHandle,
request: RequestBuilder,
) -> Result<bool, ApplicationDownloadError> {
// If we're paused
if control_flag.get() == DownloadThreadControlFlag::Stop {
progress.set(0);
return Ok(false);
}
let response = request
.header("Authorization", generate_authorization_header())
let header = generate_authorization_header();
let url = generate_url(&["/api/v2/client/chunk"], &[])
.map_err(ApplicationDownloadError::Communication)?;
let body = ChunkBody::create(ctx, &bucket.drops);
let response = DROP_CLIENT_SYNC
.post(url)
.json(&body)
.header("Authorization", header)
.send()
.map_err(|e| ApplicationDownloadError::Communication(e.into()))?;
if response.status() != 200 {
debug!("chunk request got status code: {}", response.status());
let raw_res = response.text().unwrap();
info!("chunk request got status code: {}", response.status());
let raw_res = response.text().map_err(|e| {
ApplicationDownloadError::Communication(RemoteAccessError::FetchError(e.into()))
})?;
info!("{raw_res}");
if let Ok(err) = serde_json::from_str::<DropServerError>(&raw_res) {
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(err),
@ -157,34 +183,39 @@ pub fn download_game_chunk(
));
}
let mut destination = DropWriter::new(ctx.path.clone());
let lengths = response
.headers()
.get("Content-Lengths")
.ok_or(ApplicationDownloadError::Communication(
RemoteAccessError::UnparseableResponse("missing Content-Lengths header".to_owned()),
))?
.to_str()
.unwrap();
if ctx.offset != 0 {
destination
.seek(SeekFrom::Start(ctx.offset))
.expect("Failed to seek to file offset");
}
let content_length = response.content_length();
if content_length.is_none() {
warn!("recieved 0 length content from server");
return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(response.json().unwrap()),
));
}
let length = content_length.unwrap().try_into().unwrap();
if length != ctx.length {
return Err(ApplicationDownloadError::DownloadError);
for (i, raw_length) in lengths.split(",").enumerate() {
let length = raw_length.parse::<usize>().unwrap_or(0);
let Some(drop) = bucket.drops.get(i) else {
warn!(
"invalid number of Content-Lengths recieved: {i}, {lengths}"
);
return Err(ApplicationDownloadError::DownloadError);
};
if drop.length != length {
warn!(
"for {}, expected {}, got {} ({})",
drop.filename, drop.length, raw_length, length
);
return Err(ApplicationDownloadError::DownloadError);
}
}
let mut pipeline =
DropDownloadPipeline::new(response, destination, control_flag, progress, length);
DropDownloadPipeline::new(response, bucket.drops.clone(), control_flag, progress)
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
let completed = pipeline
.copy()
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
if !completed {
return Ok(false);
}
@ -192,23 +223,25 @@ pub fn download_game_chunk(
// If we complete the file, set the permissions (if on Linux)
#[cfg(unix)]
{
let permissions = Permissions::from_mode(ctx.permissions);
set_permissions(ctx.path.clone(), permissions).unwrap();
for drop in bucket.drops.iter() {
let permissions = Permissions::from_mode(drop.permissions);
set_permissions(drop.path.clone(), permissions)
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
}
}
let checksum = pipeline
let checksums = pipeline
.finish()
.map_err(|e| ApplicationDownloadError::IoError(e.kind()))?;
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;
let res = hex::encode(checksum.0);
if res != ctx.checksum {
return Err(ApplicationDownloadError::Checksum);
for (index, drop) in bucket.drops.iter().enumerate() {
let res = hex::encode(**checksums.get(index).unwrap());
if res != drop.checksum {
warn!("context didn't match... doing nothing because we will validate later.");
// return Ok(false);
// return Err(ApplicationDownloadError::Checksum);
}
}
debug!(
"Successfully finished download #{}, copied {} bytes",
ctx.checksum, length
);
Ok(true)
}

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