Compare commits

..

11 Commits

Author SHA1 Message Date
40a490fda4 fix(playtime): remove WIP date tracking from displaying 2025-09-08 11:02:03 +10:00
6d2b8c88e8 style(playtime): add compact view and tweak style of compact playtime details 2025-09-08 11:02:02 +10:00
262c8505b7 feat(library): implement playtime tracking on the frontend 2025-09-08 11:02:02 +10:00
e798d258dc feat(playtime): add in playtime tracking commands and implement into process manager 2025-09-08 11:02:02 +10:00
105b3e9bc4 feat(databse): add in playtime tracking and bump db ver 2025-09-08 11:01:58 +10:00
3f18d15d39 Collections & download stability, UI (#130)
* feat: different local path in dev #73

* feat: better error output for downloads

* feat: collections in library view

* feat: improve download manager reliability

* feat: new download UI, more stable downloads

* fix: clippy

* fix: only show admin link if user is admin

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

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

* fix: Use Drop-OSS/native_model

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

* chore: Bump version to include logging

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

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

* chore: Make clippy happy

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

---------

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

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

* fix: remove debug utils

* fix: clippy
2025-08-28 18:05:05 +10:00
4f5fccf0c1 Add umu-run discovery (#122)
Signed-off-by: quexeky <git@quexeky.dev>
2025-08-28 18:05:05 +10:00
48 changed files with 1669 additions and 291 deletions

View File

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

View File

@ -1,5 +1,5 @@
<template> <template>
<LoadingIndicator /> <NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout class="select-none w-screen h-screen"> <NuxtLayout class="select-none w-screen h-screen">
<NuxtPage /> <NuxtPage />
<ModalStack /> <ModalStack />
@ -44,6 +44,10 @@ router.beforeEach(async () => {
setupHooks(); setupHooks();
initialNavigation(state); initialNavigation(state);
// Setup playtime event listeners
const { setupEventListeners } = usePlaytime();
setupEventListeners();
useHead({ useHead({
title: "Drop", title: "Drop",
}); });

View File

@ -37,7 +37,7 @@
</NuxtLink> </NuxtLink>
<div class="h-0.5 rounded-full w-full bg-zinc-800" /> <div class="h-0.5 rounded-full w-full bg-zinc-800" />
<div class="flex flex-col mb-1"> <div class="flex flex-col mb-1">
<MenuItem v-slot="{ active }"> <MenuItem v-if="state.user.admin" v-slot="{ active }">
<a <a
:href="adminUrl" :href="adminUrl"
target="_blank" target="_blank"

View File

@ -1,55 +1,118 @@
<template> <template>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<div class="mb-3 inline-flex gap-x-2"> <div class="mb-3 inline-flex gap-x-2">
<div class="relative transition-transform duration-300 hover:scale-105 active:scale-95"> <div
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> class="relative transition-transform duration-300 hover:scale-105 active:scale-95"
<MagnifyingGlassIcon class="h-5 w-5 text-zinc-400" aria-hidden="true" /> >
<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> </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" 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> </div>
<button @click="() => calculateGames(true)" <button
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"> @click="() => calculateGames(true, true)"
class="p-1 flex items-center justify-center transition-transform duration-300 size-10 hover:scale-110 active:scale-90 rounded-lg bg-zinc-800/50 text-zinc-100"
>
<ArrowPathIcon class="size-4" /> <ArrowPathIcon class="size-4" />
</button> </button>
</div> </div>
<TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5"> <TransitionGroup name="list" tag="ul" class="flex flex-col gap-y-1.5">
<NuxtLink v-for="(nav, navIndex) in filteredNavigation" :key="nav.id" :class="[ <Disclosure
'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', as="div"
navIndex === currentNavigation v-for="(nav, navIndex) in filteredNavigation"
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20' :key="nav.id"
: nav.isInstalled.value class="first:pt-0 last:pb-0"
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200' v-slot="{ open }"
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300', :default-open="nav.deft"
]" :href="nav.route"> >
<div class="flex items-center w-full gap-x-3"> <dt>
<div class="flex-none transition-transform duration-300 hover:-rotate-2"> <DisclosureButton
<img class="size-8 object-cover bg-zinc-900 rounded-lg transition-all duration-300 shadow-sm" class="flex w-full items-center justify-between text-left text-gray-900 dark:text-white"
:src="icons[nav.id]" alt="" /> >
</div> <span class="text-sm font-semibold font-display">{{
<div class="flex flex-col flex-1"> nav.name
<p class="truncate text-xs font-display leading-5 flex-1 font-semibold"> }}</span>
{{ nav.label }} <span class="ml-6 flex h-7 items-center">
</p> <PlusSmallIcon v-if="!open" class="size-6" aria-hidden="true" />
<p class="text-xs font-medium" :class="[gameStatusTextStyle[games[nav.id].status.value.type]]"> <MinusSmallIcon v-else class="size-6" aria-hidden="true" />
{{ gameStatusText[games[nav.id].status.value.type] }} </span>
</p> </DisclosureButton>
</div> </dt>
</div> <DisclosurePanel as="dd" class="mt-2 flex flex-col gap-y-1.5">
</NuxtLink> <NuxtLink
v-for="item in nav.items"
:key="nav.id"
:class="[
'transition-all duration-300 rounded-lg flex items-center px-1 py-1.5 hover:scale-105 active:scale-95 hover:shadow-lg hover:shadow-zinc-950/50',
currentNavigation == item.id
? 'bg-zinc-800 text-zinc-100 shadow-md shadow-zinc-950/20'
: item.isInstalled.value
? 'text-zinc-300 hover:bg-zinc-800/90 hover:text-zinc-200'
: 'text-zinc-500 hover:bg-zinc-800/70 hover:text-zinc-300',
]"
:href="item.route"
>
<div class="flex items-center w-full gap-x-2">
<div
class="flex-none transition-transform duration-300 hover:-rotate-2"
>
<img
class="size-6 object-cover bg-zinc-900 rounded transition-all duration-300 shadow-sm"
:src="icons[item.id]"
alt=""
/>
</div>
<div class="inline-flex items-center gap-x-2">
<p
class="text-sm whitespace-nowrap font-display font-semibold"
>
{{ item.label }}
</p>
<p
class="truncate text-[10px] font-bold uppercase font-display"
:class="[
gameStatusTextStyle[games[item.id].status.value.type],
]"
>
{{ gameStatusText[games[item.id].status.value.type] }}
</p>
</div>
</div>
</NuxtLink>
</DisclosurePanel>
</Disclosure>
</TransitionGroup> </TransitionGroup>
<div v-if="loading" class="h-full grow flex p-8 justify-center text-zinc-100"> <div
v-if="loading"
class="h-full grow flex p-8 justify-center text-zinc-100"
>
<div role="status"> <div role="status">
<svg aria-hidden="true" class="w-6 h-6 text-transparent animate-spin fill-zinc-600" viewBox="0 0 100 101" <svg
fill="none" xmlns="http://www.w3.org/2000/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 <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" d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor" /> fill="currentColor"
/>
<path <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" d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill" /> fill="currentFill"
/>
</svg> </svg>
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</div> </div>
@ -58,9 +121,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/vue/20/solid"; import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
import {
ArrowPathIcon,
MagnifyingGlassIcon,
MinusSmallIcon,
PlusSmallIcon,
} from "@heroicons/vue/20/solid";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { GameStatusEnum, type Game, type GameStatus } from "~/types"; import {
GameStatusEnum,
type Collection as Collection,
type Game,
type GameStatus,
} from "~/types";
import { TransitionGroup } from "vue"; import { TransitionGroup } from "vue";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
@ -70,7 +144,7 @@ const gameStatusTextStyle: { [key in GameStatusEnum]: string } = {
[GameStatusEnum.Downloading]: "text-zinc-400", [GameStatusEnum.Downloading]: "text-zinc-400",
[GameStatusEnum.Validating]: "text-blue-300", [GameStatusEnum.Validating]: "text-blue-300",
[GameStatusEnum.Running]: "text-green-500", [GameStatusEnum.Running]: "text-green-500",
[GameStatusEnum.Remote]: "text-zinc-500", [GameStatusEnum.Remote]: "text-zinc-700",
[GameStatusEnum.Queued]: "text-zinc-400", [GameStatusEnum.Queued]: "text-zinc-400",
[GameStatusEnum.Updating]: "text-zinc-400", [GameStatusEnum.Updating]: "text-zinc-400",
[GameStatusEnum.Uninstalling]: "text-zinc-100", [GameStatusEnum.Uninstalling]: "text-zinc-100",
@ -100,26 +174,47 @@ const games: {
} = {}; } = {};
const icons: { [key: string]: string } = {}; const icons: { [key: string]: string } = {};
const rawGames: Ref<Game[], Game[]> = ref([]); const collections: Ref<Collection[]> = ref([]);
async function calculateGames(clearAll = false) { async function calculateGames(clearAll = false, forceRefresh = false) {
if (clearAll) { if (clearAll) {
rawGames.value = []; collections.value = [];
loading.value = true; loading.value = true;
} }
// If we update immediately, the navigation gets re-rendered before we // If we update immediately, the navigation gets re-rendered before we
// add all the necessary state, and it freaks tf out // add all the necessary state, and it freaks tf out
const newGames = await invoke<typeof rawGames.value>("fetch_library"); const newGames = await invoke<Game[]>("fetch_library", {
for (const game of newGames) { hardRefresh: forceRefresh,
});
const otherCollections = await invoke<Collection[]>("fetch_collections", {
hardRefresh: forceRefresh,
});
const allGames = [
...newGames,
...otherCollections
.map((e) => e.entries)
.flat()
.map((e) => e.game),
].filter((v, i, a) => a.indexOf(v) === i);
for (const game of allGames) {
if (games[game.id]) continue; if (games[game.id]) continue;
games[game.id] = await useGame(game.id); games[game.id] = await useGame(game.id);
} }
for (const game of newGames) { for (const game of allGames) {
if (icons[game.id]) continue; if (icons[game.id]) continue;
icons[game.id] = await useObject(game.mIconObjectId); icons[game.id] = await useObject(game.mIconObjectId);
} }
const libraryCollection = {
id: "library",
name: "Library",
isDefault: true,
entries: newGames.map((e) => ({ gameId: e.id, game: e })),
} satisfies Collection;
loading.value = false; loading.value = false;
rawGames.value = newGames; collections.value = [libraryCollection, ...otherCollections];
} }
// Wait up to 300 ms for the library to load, otherwise // Wait up to 300 ms for the library to load, otherwise
@ -128,36 +223,43 @@ await new Promise<void>((r) => {
let hasResolved = false; let hasResolved = false;
const resolveFunc = () => { const resolveFunc = () => {
if (!hasResolved) r(); if (!hasResolved) r();
hasResolved = true hasResolved = true;
};
}
calculateGames(true).then(resolveFunc); calculateGames(true).then(resolveFunc);
setTimeout(resolveFunc, 300); setTimeout(resolveFunc, 300);
}) });
const navigation = computed(() => const navigation = computed(() =>
rawGames.value.map((game) => { collections.value.map((collection) => {
const status = games[game.id].status; const items = collection.entries.map(({ game }) => {
const status = games[game.id].status;
const isInstalled = computed( const isInstalled = computed(
() => () => status.value.type != GameStatusEnum.Remote
status.value.type != GameStatusEnum.Remote );
);
const item = { const item = {
label: game.mName, label: game.mName,
route: `/library/${game.id}`, route: `/library/${game.id}`,
prefix: `/library/${game.id}`, prefix: `/library/${game.id}`,
isInstalled, isInstalled,
id: game.id, id: game.id,
};
return item;
});
return {
id: collection.id,
name: collection.name,
deft: collection.isDefault,
items,
}; };
return item;
}) })
); );
const route = useRoute(); const route = useRoute();
const currentNavigation = computed(() => { const currentNavigation = computed(() => {
return navigation.value.findIndex((e) => e.route == route.path) return route.path.slice("/library/".length);
}); });
const filteredNavigation = computed(() => { const filteredNavigation = computed(() => {
@ -165,15 +267,18 @@ const filteredNavigation = computed(() => {
return navigation.value.map((e, i) => ({ ...e, index: i })); return navigation.value.map((e, i) => ({ ...e, index: i }));
const query = searchQuery.value.toLowerCase(); const query = searchQuery.value.toLowerCase();
return navigation.value return navigation.value
.filter((nav) => nav.label.toLowerCase().includes(query)) .map((c) => ({
.map((e, i) => ({ ...e, index: i })); ...c,
items: c.items.filter((nav) => nav.label.toLowerCase().includes(query)),
}))
.filter((e) => e.items.length > 0);
}); });
listen("update_library", async (event) => { listen("update_library", async (event) => {
console.log("Updating library"); console.log("Updating library");
let oldNavigation = navigation.value[currentNavigation.value]; let oldNavigation = currentNavigation.value;
await calculateGames(); await calculateGames();
if (oldNavigation.route !== navigation.value[currentNavigation.value].route) { if (oldNavigation !== currentNavigation.value) {
router.push("/library"); router.push("/library");
} }
}); });

View File

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

View File

@ -0,0 +1,53 @@
<template>
<div v-if="stats" class="flex flex-col gap-1">
<!-- Main playtime display -->
<div class="flex items-center gap-2">
<ClockIcon class="w-5 h-5 text-zinc-400" />
<span class="text-base text-zinc-300 font-medium">
{{ formatPlaytime(stats.totalPlaytimeSeconds) }} played
</span>
<span v-if="isActive && showActiveIndicator" class="text-sm text-green-400 font-medium">
Playing
</span>
</div>
<!-- Additional details when expanded -->
<div v-if="showDetails" class="text-xs text-zinc-400 space-y-1 ml-7">
<div>{{ stats.sessionCount }} session{{ stats.sessionCount !== 1 ? 's' : '' }}</div>
<div v-if="stats.sessionCount > 0">
Avg: {{ formatPlaytime(stats.averageSessionLength) }} per session
</div>
<div v-if="stats.currentSessionDuration">
Current session: {{ formatPlaytime(stats.currentSessionDuration) }}
</div>
</div>
</div>
<!-- No playtime data -->
<div v-else-if="showWhenEmpty" class="flex items-center gap-2 text-zinc-500">
<ClockIcon class="w-5 h-5" />
<span class="text-base">Never played</span>
</div>
</template>
<script setup lang="ts">
import { ClockIcon } from "@heroicons/vue/20/solid";
import type { GamePlaytimeStats } from "~/types";
interface Props {
stats: GamePlaytimeStats | null;
isActive?: boolean;
showDetails?: boolean;
showWhenEmpty?: boolean;
showActiveIndicator?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isActive: false,
showDetails: false,
showWhenEmpty: true,
showActiveIndicator: true,
});
const { formatPlaytime } = usePlaytime();
</script>

View File

@ -0,0 +1,76 @@
<template>
<div class="bg-zinc-800/50 rounded-lg p-4 space-y-4">
<div class="flex items-center gap-2">
<ChartBarIcon class="w-5 h-5 text-zinc-400" />
<h3 class="text-lg font-semibold text-zinc-100">Playtime Statistics</h3>
</div>
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Total Playtime -->
<div class="bg-zinc-700/50 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<ClockIcon class="w-4 h-4 text-blue-400" />
<span class="text-sm font-medium text-zinc-300">Total Playtime</span>
</div>
<div class="text-2xl font-bold text-zinc-100">
{{ formatDetailedPlaytime(stats.totalPlaytimeSeconds) }}
</div>
<div v-if="stats.currentSessionDuration" class="text-xs text-green-400 mt-1">
+{{ formatPlaytime(stats.currentSessionDuration) }} this session
</div>
</div>
<!-- Sessions -->
<div class="bg-zinc-700/50 rounded-lg p-3">
<div class="flex items-center gap-2 mb-2">
<PlayIcon class="w-4 h-4 text-green-400" />
<span class="text-sm font-medium text-zinc-300">Sessions</span>
</div>
<div class="text-2xl font-bold text-zinc-100">
{{ stats.sessionCount }}
</div>
<div class="text-xs text-zinc-400 mt-1">
Avg: {{ formatPlaytime(stats.averageSessionLength) }}
</div>
</div>
</div>
<!-- No stats available -->
<div v-else class="text-center py-8">
<ClockIcon class="w-12 h-12 text-zinc-600 mx-auto mb-3" />
<p class="text-zinc-400">No playtime data available</p>
<p class="text-sm text-zinc-500 mt-1">Statistics will appear after you start playing</p>
</div>
<!-- Current session indicator -->
<div v-if="isActive && stats" class="border-t border-zinc-700 pt-3">
<div class="flex items-center gap-2 text-green-400">
<div class="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span class="text-sm font-medium">Currently playing</span>
<span v-if="stats.currentSessionDuration" class="text-xs text-zinc-400">
{{ formatPlaytime(stats.currentSessionDuration) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ChartBarIcon,
ClockIcon,
PlayIcon
} from "@heroicons/vue/20/solid";
import type { GamePlaytimeStats } from "~/types";
interface Props {
stats: GamePlaytimeStats | null;
isActive?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isActive: false,
});
const { formatPlaytime, formatDetailedPlaytime } = usePlaytime();
</script>

View File

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

View File

@ -0,0 +1,193 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type {
GamePlaytimeStats,
PlaytimeUpdateEvent,
PlaytimeSessionStartEvent,
PlaytimeSessionEndEvent
} from "~/types";
export const usePlaytime = () => {
const playtimeStats = useState<Record<string, GamePlaytimeStats>>('playtime-stats', () => ({}));
const activeSessions = useState<Set<string>>('active-sessions', () => new Set());
// Fetch playtime stats for a specific game
const fetchGamePlaytime = async (gameId: string): Promise<GamePlaytimeStats | null> => {
try {
const stats = await invoke<GamePlaytimeStats | null>("fetch_game_playtime", { gameId });
if (stats) {
playtimeStats.value[gameId] = stats;
}
return stats;
} catch (error) {
console.error(`Failed to fetch playtime for game ${gameId}:`, error);
return null;
}
};
// Fetch all playtime stats
const fetchAllPlaytimeStats = async (): Promise<Record<string, GamePlaytimeStats>> => {
try {
const stats = await invoke<Record<string, GamePlaytimeStats>>("fetch_all_playtime_stats");
playtimeStats.value = stats;
return stats;
} catch (error) {
console.error("Failed to fetch all playtime stats:", error);
return {};
}
};
// Check if a session is active
const isSessionActive = async (gameId: string): Promise<boolean> => {
try {
return await invoke<boolean>("is_playtime_session_active", { gameId });
} catch (error) {
console.error(`Failed to check session status for game ${gameId}:`, error);
return false;
}
};
// Get all active sessions
const getActiveSessions = async (): Promise<string[]> => {
try {
const sessions = await invoke<string[]>("get_active_playtime_sessions");
activeSessions.value = new Set(sessions);
return sessions;
} catch (error) {
console.error("Failed to get active sessions:", error);
return [];
}
};
// Format playtime duration
const formatPlaytime = (seconds: number): string => {
if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `${minutes}m`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (minutes === 0) {
return `${hours}h`;
}
return `${hours}h ${minutes}m`;
}
};
// Format detailed playtime
const formatDetailedPlaytime = (seconds: number): string => {
if (seconds < 60) {
return `${seconds} seconds`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) {
return `${minutes} minutes`;
}
return `${minutes} minutes, ${remainingSeconds} seconds`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (minutes === 0) {
return `${hours} hours`;
}
return `${hours} hours, ${minutes} minutes`;
}
};
// Format relative time (e.g., "2 hours ago")
const formatRelativeTime = (timestamp: string): string => {
const date = new Date(timestamp);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return "Just now";
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
} else if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400);
return `${days} day${days !== 1 ? 's' : ''} ago`;
} else {
return date.toLocaleDateString();
}
};
// Get playtime stats for a game (from cache or fetch)
const getGamePlaytime = async (gameId: string): Promise<GamePlaytimeStats | null> => {
if (playtimeStats.value[gameId]) {
return playtimeStats.value[gameId];
}
return await fetchGamePlaytime(gameId);
};
// Setup event listeners
const setupEventListeners = () => {
// Listen for general playtime updates
listen<PlaytimeUpdateEvent>("playtime_update", (event) => {
const { gameId, stats, isActive } = event.payload;
playtimeStats.value[gameId] = stats;
if (isActive) {
activeSessions.value.add(gameId);
} else {
activeSessions.value.delete(gameId);
}
});
// Listen for session start events
listen<PlaytimeSessionStartEvent>("playtime_session_start", (event) => {
const { gameId } = event.payload;
activeSessions.value.add(gameId);
});
// Listen for session end events
listen<PlaytimeSessionEndEvent>("playtime_session_end", (event) => {
const { gameId } = event.payload;
activeSessions.value.delete(gameId);
});
};
// Setup game-specific event listeners
const setupGameEventListeners = (gameId: string) => {
listen<PlaytimeUpdateEvent>(`playtime_update/${gameId}`, (event) => {
const { stats, isActive } = event.payload;
playtimeStats.value[gameId] = stats;
if (isActive) {
activeSessions.value.add(gameId);
} else {
activeSessions.value.delete(gameId);
}
});
listen<PlaytimeSessionStartEvent>(`playtime_session_start/${gameId}`, () => {
activeSessions.value.add(gameId);
});
listen<PlaytimeSessionEndEvent>(`playtime_session_end/${gameId}`, () => {
activeSessions.value.delete(gameId);
});
};
return {
playtimeStats: readonly(playtimeStats),
activeSessions: readonly(activeSessions),
fetchGamePlaytime,
fetchAllPlaytimeStats,
isSessionActive,
getActiveSessions,
formatPlaytime,
formatDetailedPlaytime,
formatRelativeTime,
getGamePlaytime,
setupEventListeners,
setupGameEventListeners,
};
};

View File

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

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

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

View File

@ -18,11 +18,21 @@
<div class="relative z-10"> <div class="relative z-10">
<div class="px-8 pb-4"> <div class="px-8 pb-4">
<h1 <h1
class="text-5xl text-zinc-100 font-bold font-display drop-shadow-lg mb-8" class="text-5xl text-zinc-100 font-bold font-display drop-shadow-lg mb-4"
> >
{{ game.mName }} {{ game.mName }}
</h1> </h1>
<!-- Playtime Display -->
<div class="mb-8">
<PlaytimeDisplay
:stats="gamePlaytime"
:is-active="isPlaytimeActive"
:show-details="false"
:show-active-indicator="false"
/>
</div>
<div class="flex flex-row gap-x-4 items-stretch mb-8"> <div class="flex flex-row gap-x-4 items-stretch mb-8">
<!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 --> <!-- Do not add scale animations to this: https://stackoverflow.com/a/35683068 -->
<GameStatusButton <GameStatusButton
@ -60,6 +70,12 @@
</div> </div>
<div class="space-y-6"> <div class="space-y-6">
<!-- Playtime Statistics -->
<PlaytimeStats
:stats="gamePlaytime"
:is-active="isPlaytimeActive"
/>
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm"> <div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
<h2 class="text-xl font-display font-semibold text-zinc-100 mb-4"> <h2 class="text-xl font-display font-semibold text-zinc-100 mb-4">
Game Images Game Images
@ -528,6 +544,19 @@ const currentImageIndex = ref(0);
const configureModalOpen = ref(false); const configureModalOpen = ref(false);
// Playtime tracking
const {
getGamePlaytime,
setupGameEventListeners,
activeSessions
} = usePlaytime();
const gamePlaytime = ref(await getGamePlaytime(id));
const isPlaytimeActive = computed(() => activeSessions.value.has(id));
// Setup playtime event listeners for this game
setupGameEventListeners(id);
async function installFlow() { async function installFlow() {
installFlowOpen.value = true; installFlowOpen.value = true;
versionOptions.value = undefined; versionOptions.value = undefined;

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

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

View File

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

View File

@ -1,7 +1,23 @@
<template> <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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { WrenchScrewdriverIcon } from "@heroicons/vue/20/solid";
</script> </script>

View File

@ -37,6 +37,13 @@ export type Game = {
mImageCarouselObjectIds: string[]; mImageCarouselObjectIds: string[];
}; };
export type Collection = {
id: string;
name: string;
isDefault: boolean;
entries: Array<{ gameId: string; game: Game }>;
};
export type GameVersion = { export type GameVersion = {
launchCommandTemplate: string; launchCommandTemplate: string;
}; };
@ -87,3 +94,37 @@ export type Settings = {
maxDownloadThreads: number; maxDownloadThreads: number;
forceOffline: boolean; forceOffline: boolean;
}; };
export type GamePlaytimeStats = {
gameId: string;
totalPlaytimeSeconds: number;
sessionCount: number;
firstPlayed: string;
lastPlayed: string;
averageSessionLength: number;
currentSessionDuration?: number;
};
export type PlaytimeSession = {
gameId: string;
startTime: string;
sessionId: string;
};
export type PlaytimeUpdateEvent = {
gameId: string;
stats: GamePlaytimeStats;
isActive: boolean;
};
export type PlaytimeSessionStartEvent = {
gameId: string;
startTime: string;
};
export type PlaytimeSessionEndEvent = {
gameId: string;
sessionDurationSeconds: number;
totalPlaytimeSeconds: number;
sessionCount: number;
};

21
src-tauri/Cargo.lock generated
View File

@ -1284,11 +1284,12 @@ dependencies = [
[[package]] [[package]]
name = "drop-app" name = "drop-app"
version = "0.3.2" version = "0.3.3"
dependencies = [ dependencies = [
"atomic-instant-full", "atomic-instant-full",
"bitcode", "bitcode",
"boxcar", "boxcar",
"bytes",
"cacache 13.1.0", "cacache 13.1.0",
"chrono", "chrono",
"deranged", "deranged",
@ -1296,6 +1297,7 @@ dependencies = [
"droplet-rs", "droplet-rs",
"dynfmt", "dynfmt",
"filetime", "filetime",
"futures-core",
"futures-lite", "futures-lite",
"gethostname", "gethostname",
"hex 0.4.3", "hex 0.4.3",
@ -1339,6 +1341,7 @@ dependencies = [
"tempfile", "tempfile",
"throttle_my_fn", "throttle_my_fn",
"tokio", "tokio",
"tokio-util",
"umu-wrapper-lib", "umu-wrapper-lib",
"url", "url",
"urlencoding", "urlencoding",
@ -3118,13 +3121,13 @@ dependencies = [
[[package]] [[package]]
name = "native_model" name = "native_model"
version = "0.6.1" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/Drop-OSS/native_model.git#a91b422cbd53116df1f20b2459fb3d8257458bfd"
checksum = "7050d759e3da6673361dddda4f4a743492279dd2c6484a21fbee0a8278620df0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bincode", "bincode",
"doc-comment", "doc-comment",
"log",
"native_model_macro", "native_model_macro",
"rmp-serde", "rmp-serde",
"serde", "serde",
@ -3134,10 +3137,10 @@ dependencies = [
[[package]] [[package]]
name = "native_model_macro" name = "native_model_macro"
version = "0.6.1" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/Drop-OSS/native_model.git#a91b422cbd53116df1f20b2459fb3d8257458bfd"
checksum = "1577a0bebf5ed1754e240baf5d9b1845f51e598b20600aa894f55e11cd20cc6c"
dependencies = [ dependencies = [
"log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.101", "syn 2.0.101",
@ -6083,9 +6086,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.15" version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "drop-app" name = "drop-app"
version = "0.3.2" version = "0.3.3"
description = "The client application for the open-source, self-hosted game distribution platform Drop" description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"] authors = ["Drop OSS"]
edition = "2024" edition = "2024"
@ -65,7 +65,7 @@ whoami = "1.6.0"
filetime = "0.2.25" filetime = "0.2.25"
walkdir = "2.5.0" walkdir = "2.5.0"
known-folders = "1.2.0" known-folders = "1.2.0"
native_model = { version = "0.6.1", features = ["rmp_serde_1_3"] } native_model = { version = "0.6.4", features = ["rmp_serde_1_3"], git = "https://github.com/Drop-OSS/native_model.git"}
tauri-plugin-opener = "2.4.0" tauri-plugin-opener = "2.4.0"
bitcode = "0.6.6" bitcode = "0.6.6"
reqwest-websocket = "0.5.0" reqwest-websocket = "0.5.0"
@ -73,6 +73,9 @@ futures-lite = "2.6.0"
page_size = "0.6.0" page_size = "0.6.0"
sysinfo = "0.36.1" sysinfo = "0.36.1"
humansize = "2.1.3" humansize = "2.1.3"
tokio-util = { version = "0.7.16", features = ["io"] }
futures-core = "0.3.31"
bytes = "1.10.1"
# tailscale = { path = "./tailscale" } # tailscale = { path = "./tailscale" }
[dependencies.dynfmt] [dependencies.dynfmt]
@ -106,7 +109,15 @@ features = ["other_errors"] # You can also use "yaml_enc" or "bin_enc"
[dependencies.reqwest] [dependencies.reqwest]
version = "0.12.22" version = "0.12.22"
default-features = false default-features = false
features = ["json", "http2", "blocking", "rustls-tls", "native-tls-alpn", "rustls-tls-native-roots"] features = [
"json",
"http2",
"blocking",
"rustls-tls",
"native-tls-alpn",
"rustls-tls-native-roots",
"stream",
]
[dependencies.serde] [dependencies.serde]
version = "1" version = "1"

View File

@ -8,7 +8,6 @@ use std::{
use chrono::Utc; use chrono::Utc;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use native_model::{Decode, Encode};
use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError}; use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError};
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use url::Url; use url::Url;
@ -17,8 +16,13 @@ use crate::DB;
use super::models::data::Database; use super::models::data::Database;
#[cfg(not(debug_assertions))]
static DATA_ROOT_PREFIX: &'static str = "drop";
#[cfg(debug_assertions)]
static DATA_ROOT_PREFIX: &str = "drop-debug";
pub static DATA_ROOT_DIR: LazyLock<Arc<PathBuf>> = pub static DATA_ROOT_DIR: LazyLock<Arc<PathBuf>> =
LazyLock::new(|| Arc::new(dirs::data_dir().unwrap().join("drop"))); LazyLock::new(|| Arc::new(dirs::data_dir().unwrap().join(DATA_ROOT_PREFIX)));
// Custom JSON serializer to support everything we need // Custom JSON serializer to support everything we need
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@ -28,7 +32,7 @@ impl<T: native_model::Model + Serialize + DeserializeOwned> DeSerializer<T>
for DropDatabaseSerializer for DropDatabaseSerializer
{ {
fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> { fn serialize(&self, val: &T) -> rustbreak::error::DeSerResult<Vec<u8>> {
native_model::rmp_serde_1_3::RmpSerde::encode(val) native_model::encode(val)
.map_err(|e| DeSerError::Internal(e.to_string())) .map_err(|e| DeSerError::Internal(e.to_string()))
} }
@ -36,7 +40,7 @@ impl<T: native_model::Model + Serialize + DeserializeOwned> DeSerializer<T>
let mut buf = Vec::new(); let mut buf = Vec::new();
s.read_to_end(&mut buf) s.read_to_end(&mut buf)
.map_err(|e| rustbreak::error::DeSerError::Other(e.into()))?; .map_err(|e| rustbreak::error::DeSerError::Other(e.into()))?;
let val = native_model::rmp_serde_1_3::RmpSerde::decode(buf) let (val, _version) = native_model::decode(buf)
.map_err(|e| DeSerError::Internal(e.to_string()))?; .map_err(|e| DeSerError::Internal(e.to_string()))?;
Ok(val) Ok(val)
} }

View File

@ -1,32 +1,45 @@
/**
* NEXT BREAKING CHANGE
*
* UPDATE DATABASE TO USE RPMSERDENAMED
*
* WE CAN'T DELETE ANY FIELDS
*/
pub mod data { pub mod data {
use std::path::PathBuf; use std::{hash::Hash, path::PathBuf};
use native_model::native_model; use native_model::native_model;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// NOTE: Within each version, you should NEVER use these types.
// Declare it using the actual version that it is from, i.e. v1::Settings rather than just Settings from here
pub type GameVersion = v1::GameVersion; pub type GameVersion = v1::GameVersion;
pub type Database = v3::Database; pub type Database = v4::Database;
pub type Settings = v1::Settings; pub type Settings = v1::Settings;
pub type DatabaseAuth = v1::DatabaseAuth; pub type DatabaseAuth = v1::DatabaseAuth;
pub type GameDownloadStatus = v2::GameDownloadStatus; pub type GameDownloadStatus = v2::GameDownloadStatus;
pub type ApplicationTransientStatus = v1::ApplicationTransientStatus; pub type ApplicationTransientStatus = v1::ApplicationTransientStatus;
/**
* Need to be universally accessible by the ID, and the version is just a couple sprinkles on top
*/
pub type DownloadableMetadata = v1::DownloadableMetadata; pub type DownloadableMetadata = v1::DownloadableMetadata;
pub type DownloadType = v1::DownloadType; pub type DownloadType = v1::DownloadType;
pub type DatabaseApplications = v2::DatabaseApplications; pub type DatabaseApplications = v2::DatabaseApplications;
pub type DatabaseCompatInfo = v2::DatabaseCompatInfo; //pub type DatabaseCompatInfo = v2::DatabaseCompatInfo;
pub type PlaytimeData = v4::PlaytimeData;
pub type GamePlaytimeStats = v4::GamePlaytimeStats;
pub type PlaytimeSession = v4::PlaytimeSession;
use std::collections::HashMap; use std::collections::HashMap;
pub mod v1 { impl PartialEq for DownloadableMetadata {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.download_type == other.download_type
}
}
impl Hash for DownloadableMetadata {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
self.download_type.hash(state);
}
}
mod v1 {
use crate::process::process_manager::Platform; use crate::process::process_manager::Platform;
use serde_with::serde_as; use serde_with::serde_as;
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
@ -116,6 +129,7 @@ pub mod data {
// Stuff that shouldn't be synced to disk // Stuff that shouldn't be synced to disk
#[derive(Clone, Serialize, Deserialize, Debug)] #[derive(Clone, Serialize, Deserialize, Debug)]
pub enum ApplicationTransientStatus { pub enum ApplicationTransientStatus {
Queued { version_name: String },
Downloading { version_name: String }, Downloading { version_name: String },
Uninstalling {}, Uninstalling {},
Updating { version_name: String }, Updating { version_name: String },
@ -144,7 +158,7 @@ pub mod data {
} }
#[native_model(id = 7, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)] #[native_model(id = 7, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Clone)] #[derive(Debug, Eq, PartialOrd, Ord, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadableMetadata { pub struct DownloadableMetadata {
pub id: String, pub id: String,
@ -174,22 +188,21 @@ pub mod data {
} }
} }
pub mod v2 { mod v2 {
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
use serde_with::serde_as; use serde_with::serde_as;
use super::{ use super::{
ApplicationTransientStatus, DatabaseAuth, Deserialize, DownloadableMetadata, Deserialize, Serialize, native_model, v1,
GameVersion, Serialize, Settings, native_model, v1,
}; };
#[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)] #[native_model(id = 1, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::Database)]
#[derive(Serialize, Deserialize, Clone, Default)] #[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database { pub struct Database {
#[serde(default)] #[serde(default)]
pub settings: Settings, pub settings: v1::Settings,
pub auth: Option<DatabaseAuth>, pub auth: Option<v1::DatabaseAuth>,
pub base_url: String, pub base_url: String,
pub applications: v1::DatabaseApplications, pub applications: v1::DatabaseApplications,
#[serde(skip)] #[serde(skip)]
@ -198,7 +211,7 @@ pub mod data {
pub compat_info: Option<DatabaseCompatInfo>, pub compat_info: Option<DatabaseCompatInfo>,
} }
#[native_model(id = 8, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)] #[native_model(id = 9, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Serialize, Deserialize, Clone, Default)] #[derive(Serialize, Deserialize, Clone, Default)]
pub struct DatabaseCompatInfo { pub struct DatabaseCompatInfo {
@ -221,7 +234,7 @@ pub mod data {
// Strings are version names for a particular game // Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize, Debug)] #[derive(Serialize, Clone, Deserialize, Debug)]
#[serde(tag = "type")] #[serde(tag = "type")]
#[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)] #[native_model(id = 5, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from = v1::GameDownloadStatus)]
pub enum GameDownloadStatus { pub enum GameDownloadStatus {
Remote {}, Remote {},
SetupRequired { SetupRequired {
@ -261,17 +274,17 @@ pub mod data {
#[serde_as] #[serde_as]
#[derive(Serialize, Clone, Deserialize, Default)] #[derive(Serialize, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde)] #[native_model(id = 3, version = 2, with = native_model::rmp_serde_1_3::RmpSerde, from=v1::DatabaseApplications)]
pub struct DatabaseApplications { pub struct DatabaseApplications {
pub install_dirs: Vec<PathBuf>, pub install_dirs: Vec<PathBuf>,
// Guaranteed to exist if the game also exists in the app state map // Guaranteed to exist if the game also exists in the app state map
pub game_statuses: HashMap<String, GameDownloadStatus>, pub game_statuses: HashMap<String, GameDownloadStatus>,
pub game_versions: HashMap<String, HashMap<String, GameVersion>>, pub game_versions: HashMap<String, HashMap<String, v1::GameVersion>>,
pub installed_game_version: HashMap<String, DownloadableMetadata>, pub installed_game_version: HashMap<String, v1::DownloadableMetadata>,
#[serde(skip)] #[serde(skip)]
pub transient_statuses: HashMap<DownloadableMetadata, ApplicationTransientStatus>, pub transient_statuses: HashMap<v1::DownloadableMetadata, v1::ApplicationTransientStatus>,
} }
impl From<v1::DatabaseApplications> for DatabaseApplications { impl From<v1::DatabaseApplications> for DatabaseApplications {
fn from(value: v1::DatabaseApplications) -> Self { fn from(value: v1::DatabaseApplications) -> Self {
@ -293,21 +306,21 @@ pub mod data {
use std::path::PathBuf; use std::path::PathBuf;
use super::{ use super::{
DatabaseApplications, DatabaseAuth, DatabaseCompatInfo, Deserialize, Serialize, Deserialize, Serialize,
Settings, native_model, v2, native_model, v2, v1,
}; };
#[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde)] #[native_model(id = 1, version = 3, with = native_model::rmp_serde_1_3::RmpSerde, from = v2::Database)]
#[derive(Serialize, Deserialize, Clone, Default)] #[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database { pub struct Database {
#[serde(default)] #[serde(default)]
pub settings: Settings, pub settings: v1::Settings,
pub auth: Option<DatabaseAuth>, pub auth: Option<v1::DatabaseAuth>,
pub base_url: String, pub base_url: String,
pub applications: DatabaseApplications, pub applications: v2::DatabaseApplications,
#[serde(skip)] #[serde(skip)]
pub prev_database: Option<PathBuf>, pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf, pub cache_dir: PathBuf,
pub compat_info: Option<DatabaseCompatInfo>, pub compat_info: Option<v2::DatabaseCompatInfo>,
} }
impl From<v2::Database> for Database { impl From<v2::Database> for Database {
@ -345,7 +358,110 @@ pub mod data {
settings: Settings::default(), settings: Settings::default(),
cache_dir, cache_dir,
compat_info: None, compat_info: None,
playtime_data: PlaytimeData::default(),
} }
} }
} }
mod v4 {
use std::{collections::HashMap, path::PathBuf, time::SystemTime};
use super::{
DatabaseApplications, DatabaseAuth, DatabaseCompatInfo, Deserialize, Serialize,
Settings, native_model, v3,
};
#[native_model(id = 1, version = 4, with = native_model::rmp_serde_1_3::RmpSerde)]
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Database {
#[serde(default)]
pub settings: Settings,
pub auth: Option<DatabaseAuth>,
pub base_url: String,
pub applications: DatabaseApplications,
#[serde(skip)]
pub prev_database: Option<PathBuf>,
pub cache_dir: PathBuf,
pub compat_info: Option<DatabaseCompatInfo>,
#[serde(default)]
pub playtime_data: PlaytimeData,
}
#[derive(Serialize, Deserialize, Clone, Default)]
#[native_model(id = 9, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct PlaytimeData {
pub game_sessions: HashMap<String, GamePlaytimeStats>,
#[serde(skip)]
pub active_sessions: HashMap<String, PlaytimeSession>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[native_model(id = 10, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct GamePlaytimeStats {
pub game_id: String,
pub total_playtime_seconds: u64,
pub session_count: u32,
pub first_played: SystemTime,
pub last_played: SystemTime,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[native_model(id = 11, version = 1, with = native_model::rmp_serde_1_3::RmpSerde)]
pub struct PlaytimeSession {
pub game_id: String,
pub start_time: SystemTime,
pub session_id: String,
}
impl GamePlaytimeStats {
pub fn new(game_id: String) -> Self {
let now = SystemTime::now();
Self {
game_id,
total_playtime_seconds: 0,
session_count: 0,
first_played: now,
last_played: now,
}
}
pub fn average_session_length(&self) -> u64 {
if self.session_count == 0 {
0
} else {
self.total_playtime_seconds / self.session_count as u64
}
}
}
impl PlaytimeSession {
pub fn new(game_id: String) -> Self {
Self {
game_id,
start_time: SystemTime::now(),
session_id: uuid::Uuid::new_v4().to_string(),
}
}
pub fn duration(&self) -> std::time::Duration {
self.start_time.elapsed().unwrap_or_default()
}
}
impl From<v3::Database> for Database {
fn from(value: v3::Database) -> Self {
Self {
settings: value.settings,
auth: value.auth,
base_url: value.base_url,
applications: value.applications,
prev_database: value.prev_database,
cache_dir: value.cache_dir,
compat_info: value.compat_info,
playtime_data: PlaytimeData::default(),
}
}
}
}
} }

View File

@ -5,10 +5,10 @@ use log::warn;
use crate::{ use crate::{
database::{ database::{
db::borrow_db_mut_checked, db::borrow_db_mut_checked,
models::data::v1::{DownloadType, DownloadableMetadata}, models::data::{DownloadType, DownloadableMetadata},
}, },
games::{ games::{
downloads::drop_data::{v1::DropData, DROP_DATA_PATH}, downloads::drop_data::{DropData, DROP_DATA_PATH},
library::set_partially_installed_db, library::set_partially_installed_db,
}, },
}; };

View File

@ -12,6 +12,7 @@ use tauri::{AppHandle, Emitter};
use crate::{ use crate::{
database::models::data::DownloadableMetadata, database::models::data::DownloadableMetadata,
download_manager::download_manager_frontend::DownloadStatus,
error::application_download_error::ApplicationDownloadError, error::application_download_error::ApplicationDownloadError,
games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent}, games::library::{QueueUpdateEvent, QueueUpdateEventQueueData, StatsUpdateEvent},
}; };
@ -75,7 +76,6 @@ pub struct DownloadManagerBuilder {
status: Arc<Mutex<DownloadManagerStatus>>, status: Arc<Mutex<DownloadManagerStatus>>,
app_handle: AppHandle, 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<()>>>, current_download_thread: Mutex<Option<JoinHandle<()>>>,
active_control_flag: Option<DownloadThreadControl>, active_control_flag: Option<DownloadThreadControl>,
} }
@ -95,7 +95,6 @@ impl DownloadManagerBuilder {
progress: active_progress.clone(), progress: active_progress.clone(),
app_handle, app_handle,
current_download_agent: None,
current_download_thread: Mutex::new(None), current_download_thread: Mutex::new(None),
active_control_flag: None, active_control_flag: None,
}; };
@ -121,7 +120,6 @@ impl DownloadManagerBuilder {
fn cleanup_current_download(&mut self) { fn cleanup_current_download(&mut self) {
self.active_control_flag = None; self.active_control_flag = None;
*self.progress.lock().unwrap() = None; *self.progress.lock().unwrap() = None;
self.current_download_agent = None;
let mut download_thread_lock = self.current_download_thread.lock().unwrap(); let mut download_thread_lock = self.current_download_thread.lock().unwrap();
@ -197,7 +195,7 @@ impl DownloadManagerBuilder {
return; return;
} }
download_agent.on_initialised(&self.app_handle); download_agent.on_queued(&self.app_handle);
self.download_queue.append(meta.clone()); self.download_queue.append(meta.clone());
self.download_agent_registry.insert(meta, download_agent); self.download_agent_registry.insert(meta, download_agent);
@ -216,19 +214,13 @@ impl DownloadManagerBuilder {
return; return;
} }
if self.current_download_agent.is_some()
&& self.download_queue.read().front().unwrap()
== &self.current_download_agent.as_ref().unwrap().metadata()
{
return;
}
debug!("current download queue: {:?}", self.download_queue.read()); debug!("current download queue: {:?}", self.download_queue.read());
// Should always be Some if the above two statements keep going let agent_data = if let Some(agent_data) = self.download_queue.read().front() {
let agent_data = self.download_queue.read().front().unwrap().clone(); agent_data.clone()
} else {
info!("starting download for {agent_data:?}"); return;
};
let download_agent = self let download_agent = self
.download_agent_registry .download_agent_registry
@ -236,8 +228,22 @@ impl DownloadManagerBuilder {
.unwrap() .unwrap()
.clone(); .clone();
let status = download_agent.status();
// This download is already going
if status != DownloadStatus::Queued {
return;
}
// Ensure all others are marked as queued
for agent in self.download_agent_registry.values() {
if agent.metadata() != agent_data && agent.status() != DownloadStatus::Queued {
agent.on_queued(&self.app_handle);
}
}
info!("starting download for {agent_data:?}");
self.active_control_flag = Some(download_agent.control_flag()); self.active_control_flag = Some(download_agent.control_flag());
self.current_download_agent = Some(download_agent.clone());
let sender = self.sender.clone(); let sender = self.sender.clone();
@ -310,8 +316,8 @@ impl DownloadManagerBuilder {
} }
fn manage_completed_signal(&mut self, meta: DownloadableMetadata) { fn manage_completed_signal(&mut self, meta: DownloadableMetadata) {
debug!("got signal Completed"); debug!("got signal Completed");
if let Some(interface) = &self.current_download_agent if let Some(interface) = self.download_queue.read().front()
&& interface.metadata() == meta && interface == &meta
{ {
self.remove_and_cleanup_front_download(&meta); self.remove_and_cleanup_front_download(&meta);
} }
@ -321,11 +327,13 @@ impl DownloadManagerBuilder {
} }
fn manage_error_signal(&mut self, error: ApplicationDownloadError) { fn manage_error_signal(&mut self, error: ApplicationDownloadError) {
debug!("got signal Error"); debug!("got signal Error");
if let Some(current_agent) = self.current_download_agent.clone() { if let Some(metadata) = self.download_queue.read().front()
&& let Some(current_agent) = self.download_agent_registry.get(metadata)
{
current_agent.on_error(&self.app_handle, &error); current_agent.on_error(&self.app_handle, &error);
self.stop_and_wait_current_download(); self.stop_and_wait_current_download();
self.remove_and_cleanup_front_download(&current_agent.metadata()); self.remove_and_cleanup_front_download(metadata);
} }
self.push_ui_queue_update(); self.push_ui_queue_update();
self.set_status(DownloadManagerStatus::Error); self.set_status(DownloadManagerStatus::Error);
@ -333,32 +341,23 @@ impl DownloadManagerBuilder {
fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) { fn manage_cancel_signal(&mut self, meta: &DownloadableMetadata) {
debug!("got signal Cancel"); debug!("got signal Cancel");
if let Some(current_download) = &self.current_download_agent { // If the current download is the one we're tryna cancel
if &current_download.metadata() == meta { if let Some(current_metadata) = self.download_queue.read().front()
self.set_status(DownloadManagerStatus::Paused); && current_metadata == meta
current_download.on_cancelled(&self.app_handle); && let Some(current_download) = self.download_agent_registry.get(current_metadata)
self.stop_and_wait_current_download(); {
self.set_status(DownloadManagerStatus::Paused);
current_download.on_cancelled(&self.app_handle);
self.stop_and_wait_current_download();
self.download_queue.pop_front(); self.download_queue.pop_front();
self.cleanup_current_download(); self.cleanup_current_download();
debug!("current download queue: {:?}", self.download_queue.read()); self.download_agent_registry.remove(meta);
} debug!("current download queue: {:?}", self.download_queue.read());
// TODO: Collapse these two into a single if statement somehow }
else if let Some(download_agent) = self.download_agent_registry.get(meta) { // else just cancel it
let index = self.download_queue.get_by_meta(meta); else if let Some(download_agent) = self.download_agent_registry.get(meta) {
if let Some(index) = index {
download_agent.on_cancelled(&self.app_handle);
let _ = self.download_queue.edit().remove(index).unwrap();
let removed = self.download_agent_registry.remove(meta);
debug!(
"removed {:?} from queue {:?}",
removed.map(|x| x.metadata()),
self.download_queue.read()
);
}
}
} else if let Some(download_agent) = self.download_agent_registry.get(meta) {
let index = self.download_queue.get_by_meta(meta); let index = self.download_queue.get_by_meta(meta);
if let Some(index) = index { if let Some(index) = index {
download_agent.on_cancelled(&self.app_handle); download_agent.on_cancelled(&self.app_handle);
@ -371,6 +370,7 @@ impl DownloadManagerBuilder {
); );
} }
} }
self.sender.send(DownloadManagerSignal::Go).unwrap();
self.push_ui_queue_update(); self.push_ui_queue_update();
} }
fn push_ui_stats_update(&self, kbs: usize, time: usize) { fn push_ui_stats_update(&self, kbs: usize, time: usize) {

View File

@ -62,7 +62,7 @@ impl Serialize for DownloadManagerStatus {
} }
} }
#[derive(Serialize, Clone, Debug)] #[derive(Serialize, Clone, Debug, PartialEq)]
pub enum DownloadStatus { pub enum DownloadStatus {
Queued, Queued,
Downloading, Downloading,

View File

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

View File

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

View File

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

View File

@ -11,7 +11,8 @@ pub enum ProcessError {
IOError(Error), IOError(Error),
FormatError(String), // String errors supremacy FormatError(String), // String errors supremacy
InvalidPlatform, InvalidPlatform,
OpenerError(tauri_plugin_opener::Error) OpenerError(tauri_plugin_opener::Error),
PlaytimeError(String),
} }
impl Display for ProcessError { impl Display for ProcessError {
@ -25,6 +26,7 @@ impl Display for ProcessError {
ProcessError::InvalidPlatform => "This game cannot be played on the current platform", ProcessError::InvalidPlatform => "This game cannot be played on the current platform",
ProcessError::FormatError(e) => &format!("Failed to format template: {e}"), ProcessError::FormatError(e) => &format!("Failed to format template: {e}"),
ProcessError::OpenerError(error) => &format!("Failed to open directory: {error}"), ProcessError::OpenerError(error) => &format!("Failed to open directory: {error}"),
ProcessError::PlaytimeError(error) => &format!("Playtime tracking error: {error}"),
}; };
write!(f, "{s}") write!(f, "{s}")
} }

View File

@ -23,6 +23,7 @@ pub enum RemoteAccessError {
ManifestDownloadFailed(StatusCode, String), ManifestDownloadFailed(StatusCode, String),
OutOfSync, OutOfSync,
Cache(std::io::Error), Cache(std::io::Error),
CorruptedState,
} }
impl Display for RemoteAccessError { 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" "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::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,10 +1,11 @@
use bitcode::{Decode, Encode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::games::library::Game; use crate::games::library::Game;
pub type Collections = Vec<Collection>; pub type Collections = Vec<Collection>;
#[derive(Serialize, Deserialize, Debug, Clone, Default)] #[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Collection { pub struct Collection {
id: String, id: String,
@ -14,7 +15,7 @@ pub struct Collection {
entries: Vec<CollectionObject>, entries: Vec<CollectionObject>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, Default)] #[derive(Serialize, Deserialize, Debug, Clone, Default, Encode, Decode)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CollectionObject { pub struct CollectionObject {
collection_id: String, collection_id: String,

View File

@ -4,6 +4,7 @@ use crate::{
error::remote_access_error::RemoteAccessError, error::remote_access_error::RemoteAccessError,
remote::{ remote::{
auth::generate_authorization_header, auth::generate_authorization_header,
cache::{cache_object, get_cached_object},
requests::{generate_url, make_authenticated_get}, requests::{generate_url, make_authenticated_get},
utils::DROP_CLIENT_ASYNC, utils::DROP_CLIENT_ASYNC,
}, },
@ -12,11 +13,23 @@ use crate::{
use super::collection::{Collection, Collections}; use super::collection::{Collection, Collections};
#[tauri::command] #[tauri::command]
pub async fn fetch_collections() -> Result<Collections, RemoteAccessError> { pub async fn fetch_collections(
hard_refresh: Option<bool>,
) -> Result<Collections, RemoteAccessError> {
let do_hard_refresh = hard_refresh.unwrap_or(false);
if !do_hard_refresh && let Ok(cached_response) = get_cached_object::<Collections>("collections")
{
return Ok(cached_response);
}
let response = let response =
make_authenticated_get(generate_url(&["/api/v1/client/collection"], &[])?).await?; make_authenticated_get(generate_url(&["/api/v1/client/collection"], &[])?).await?;
Ok(response.json().await?) let collections: Collections = response.json().await?;
cache_object("collections", &collections)?;
Ok(collections)
} }
#[tauri::command] #[tauri::command]
@ -90,7 +103,8 @@ pub async fn delete_game_in_collection(
.delete(url) .delete(url)
.header("Authorization", generate_authorization_header()) .header("Authorization", generate_authorization_header())
.json(&json!({"id": game_id})) .json(&json!({"id": game_id}))
.send().await?; .send()
.await?;
Ok(()) Ok(())
} }

View File

@ -27,12 +27,14 @@ use super::{
#[tauri::command] #[tauri::command]
pub async fn fetch_library( pub async fn fetch_library(
state: tauri::State<'_, Mutex<AppState<'_>>>, state: tauri::State<'_, Mutex<AppState<'_>>>,
hard_refresh: Option<bool>,
) -> Result<Vec<Game>, RemoteAccessError> { ) -> Result<Vec<Game>, RemoteAccessError> {
offline!( offline!(
state, state,
fetch_library_logic, fetch_library_logic,
fetch_library_logic_offline, fetch_library_logic_offline,
state state,
hard_refresh
).await ).await
} }

View File

@ -22,7 +22,7 @@ use crate::remote::requests::generate_url;
use crate::remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC}; use crate::remote::utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rayon::ThreadPoolBuilder; use rayon::ThreadPoolBuilder;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::fs::{OpenOptions, create_dir_all}; use std::fs::{OpenOptions, create_dir_all};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
@ -39,6 +39,7 @@ use super::drop_data::DropData;
static RETRY_COUNT: usize = 3; static RETRY_COUNT: usize = 3;
const TARGET_BUCKET_SIZE: usize = 63 * 1000 * 1000; const TARGET_BUCKET_SIZE: usize = 63 * 1000 * 1000;
const MAX_FILES_PER_BUCKET: usize = (1024 / 4) - 1;
pub struct GameDownloadAgent { pub struct GameDownloadAgent {
pub id: String, pub id: String,
@ -83,6 +84,8 @@ impl GameDownloadAgent {
let stored_manifest = let stored_manifest =
DropData::generate(id.clone(), version.clone(), data_base_dir_path.clone()); DropData::generate(id.clone(), version.clone(), data_base_dir_path.clone());
let context_lock = stored_manifest.contexts.lock().unwrap().clone();
let result = Self { let result = Self {
id, id,
version, version,
@ -105,7 +108,14 @@ impl GameDownloadAgent {
.as_ref() .as_ref()
.unwrap() .unwrap()
.values() .values()
.map(|e| e.lengths.iter().sum::<usize>()) .map(|e| {
e.lengths
.iter()
.enumerate()
.filter(|(i, _)| *context_lock.get(&e.checksums[*i]).unwrap_or(&false))
.map(|(_, v)| v)
.sum::<usize>()
})
.sum::<usize>() as u64; .sum::<usize>() as u64;
let available_space = get_disk_available(data_base_dir_path)? as u64; let available_space = get_disk_available(data_base_dir_path)? as u64;
@ -242,12 +252,8 @@ impl GameDownloadAgent {
let mut buckets = Vec::new(); let mut buckets = Vec::new();
let mut current_bucket = DownloadBucket { let mut current_buckets = HashMap::<String, DownloadBucket>::new();
game_id: game_id.clone(), let mut current_bucket_sizes = HashMap::<String, usize>::new();
version: self.version.clone(),
drops: Vec::new(),
};
let mut current_bucket_size = 0;
for (raw_path, chunk) in manifest { for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path)); let path = base_path.join(Path::new(&raw_path));
@ -282,28 +288,42 @@ impl GameDownloadAgent {
buckets.push(DownloadBucket { buckets.push(DownloadBucket {
game_id: game_id.clone(), game_id: game_id.clone(),
version: self.version.clone(), version: chunk.version_name.clone(),
drops: vec![drop], drops: vec![drop],
}); });
continue; continue;
} }
if current_bucket_size + *length >= TARGET_BUCKET_SIZE let current_bucket_size = current_bucket_sizes
.entry(chunk.version_name.clone())
.or_insert_with(|| 0);
let c_version_name = chunk.version_name.clone();
let c_game_id = game_id.clone();
let current_bucket = current_buckets
.entry(chunk.version_name.clone())
.or_insert_with(|| DownloadBucket {
game_id: c_game_id,
version: c_version_name,
drops: vec![],
});
if (*current_bucket_size + length >= TARGET_BUCKET_SIZE
|| current_bucket.drops.len() >= MAX_FILES_PER_BUCKET)
&& !current_bucket.drops.is_empty() && !current_bucket.drops.is_empty()
{ {
// Move current bucket into list and make a new one // Move current bucket into list and make a new one
buckets.push(current_bucket); buckets.push(current_bucket.clone());
current_bucket = DownloadBucket { *current_bucket = DownloadBucket {
game_id: game_id.clone(), game_id: game_id.clone(),
version: self.version.clone(), version: chunk.version_name.clone(),
drops: Vec::new(), drops: vec![],
}; };
current_bucket_size = 0; *current_bucket_size = 0;
} }
current_bucket.drops.push(drop); current_bucket.drops.push(drop);
current_bucket_size += *length; *current_bucket_size += *length;
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@ -312,8 +332,10 @@ impl GameDownloadAgent {
} }
} }
if !current_bucket.drops.is_empty() { for (_, bucket) in current_buckets.into_iter() {
buckets.push(current_bucket); if !bucket.drops.is_empty() {
buckets.push(bucket);
}
} }
info!("buckets: {}", buckets.len()); info!("buckets: {}", buckets.len());
@ -348,27 +370,47 @@ impl GameDownloadAgent {
.build() .build()
.unwrap(); .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_contexts = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_contexts.clone(); let completed_indexes_loop_arc = completed_contexts.clone();
let download_context = DROP_CLIENT_SYNC for version in versions {
.post(generate_url(&["/api/v2/client/context"], &[]).unwrap()) let download_context = DROP_CLIENT_SYNC
.json(&ManifestBody { .post(generate_url(&["/api/v2/client/context"], &[]).unwrap())
game: self.id.clone(), .json(&ManifestBody {
version: self.version.clone(), game: self.id.clone(),
}) version: version.clone(),
.header("Authorization", generate_authorization_header()) })
.send()?; .header("Authorization", generate_authorization_header())
.send()?;
if download_context.status() != 200 { if download_context.status() != 200 {
return Err(RemoteAccessError::InvalidResponse(download_context.json()?)); 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_context = &download_context.json::<DownloadContext>()?; let download_contexts = &download_contexts;
info!("download context: {}", download_context.context);
let buckets = self.buckets.lock().unwrap();
pool.scope(|scope| { pool.scope(|scope| {
let context_map = self.context_map.lock().unwrap(); let context_map = self.context_map.lock().unwrap();
for (index, bucket) in buckets.iter().enumerate() { for (index, bucket) in buckets.iter().enumerate() {
@ -400,6 +442,11 @@ impl GameDownloadAgent {
let sender = self.sender.clone(); let sender = self.sender.clone();
let download_context = download_contexts
.get(&bucket.version)
.ok_or(RemoteAccessError::CorruptedState)
.unwrap();
scope.spawn(move |_| { scope.spawn(move |_| {
// 3 attempts // 3 attempts
for i in 0..RETRY_COUNT { for i in 0..RETRY_COUNT {
@ -425,6 +472,7 @@ impl GameDownloadAgent {
ApplicationDownloadError::Communication(_) ApplicationDownloadError::Communication(_)
| ApplicationDownloadError::Checksum | ApplicationDownloadError::Checksum
| ApplicationDownloadError::Lock | ApplicationDownloadError::Lock
| ApplicationDownloadError::IoError(_)
); );
if i == RETRY_COUNT - 1 || !retry { if i == RETRY_COUNT - 1 || !retry {
@ -591,8 +639,17 @@ impl Downloadable for GameDownloadAgent {
} }
} }
fn on_initialised(&self, _app_handle: &tauri::AppHandle) { fn on_queued(&self, app_handle: &tauri::AppHandle) {
*self.status.lock().unwrap() = DownloadStatus::Queued; *self.status.lock().unwrap() = DownloadStatus::Queued;
let mut db_lock = borrow_db_mut_checked();
let status = ApplicationTransientStatus::Queued {
version_name: self.version.clone(),
};
db_lock
.applications
.transient_statuses
.insert(self.metadata(), status.clone());
push_game_update(app_handle, &self.id, None, (None, Some(status)));
} }
fn on_error(&self, app_handle: &tauri::AppHandle, error: &ApplicationDownloadError) { fn on_error(&self, app_handle: &tauri::AppHandle, error: &ApplicationDownloadError) {
@ -601,7 +658,7 @@ impl Downloadable for GameDownloadAgent {
.emit("download_error", error.to_string()) .emit("download_error", error.to_string())
.unwrap(); .unwrap();
error!("error while managing download: {error}"); error!("error while managing download: {error:?}");
let mut handle = borrow_db_mut_checked(); let mut handle = borrow_db_mut_checked();
handle handle
@ -627,15 +684,8 @@ impl Downloadable for GameDownloadAgent {
} }
fn on_cancelled(&self, app_handle: &tauri::AppHandle) { fn on_cancelled(&self, app_handle: &tauri::AppHandle) {
info!("cancelled {}", self.id);
self.cancel(app_handle); self.cancel(app_handle);
/*
on_game_incomplete(
&self.metadata(),
self.dropdata.base_path.to_string_lossy().to_string(),
app_handle,
)
.unwrap();
*/
} }
fn status(&self) -> DownloadStatus { fn status(&self) -> DownloadStatus {

View File

@ -9,7 +9,7 @@ use crate::games::downloads::manifest::{ChunkBody, DownloadBucket, DownloadConte
use crate::remote::auth::generate_authorization_header; use crate::remote::auth::generate_authorization_header;
use crate::remote::requests::generate_url; use crate::remote::requests::generate_url;
use crate::remote::utils::DROP_CLIENT_SYNC; use crate::remote::utils::DROP_CLIENT_SYNC;
use log::{info, warn}; use log::{debug, info, warn};
use md5::{Context, Digest}; use md5::{Context, Digest};
use reqwest::blocking::Response; use reqwest::blocking::Response;
@ -18,6 +18,7 @@ use std::io::Read;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use std::{ use std::{
fs::{File, OpenOptions}, fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write}, io::{self, BufWriter, Seek, SeekFrom, Write},
@ -25,6 +26,7 @@ use std::{
}; };
static MAX_PACKET_LENGTH: usize = 4096 * 4; static MAX_PACKET_LENGTH: usize = 4096 * 4;
static BUMP_SIZE: usize = 4096 * 16;
pub struct DropWriter<W: Write> { pub struct DropWriter<W: Write> {
hasher: Context, hasher: Context,
@ -79,6 +81,8 @@ pub struct DropDownloadPipeline<'a, R: Read, W: Write> {
pub drops: Vec<DownloadDrop>, pub drops: Vec<DownloadDrop>,
pub destination: Vec<DropWriter<W>>, pub destination: Vec<DropWriter<W>>,
pub control_flag: &'a DownloadThreadControl, pub control_flag: &'a DownloadThreadControl,
#[allow(dead_code)]
progress: ProgressHandle,
} }
impl<'a> DropDownloadPipeline<'a, Response, File> { impl<'a> DropDownloadPipeline<'a, Response, File> {
@ -96,6 +100,7 @@ impl<'a> DropDownloadPipeline<'a, Response, File> {
.try_collect()?, .try_collect()?,
drops, drops,
control_flag, control_flag,
progress,
}) })
} }
@ -111,13 +116,24 @@ impl<'a> DropDownloadPipeline<'a, Response, File> {
if drop.start != 0 { if drop.start != 0 {
destination.seek(SeekFrom::Start(drop.start.try_into().unwrap()))?; destination.seek(SeekFrom::Start(drop.start.try_into().unwrap()))?;
} }
let mut last_bump = 0;
loop { loop {
let size = MAX_PACKET_LENGTH.min(remaining); let size = MAX_PACKET_LENGTH.min(remaining);
self.source.read_exact(&mut copy_buffer[0..size])?; let size = self.source.read(&mut copy_buffer[0..size]).inspect_err(|_| {
info!("got error from {}", drop.filename);
})?;
remaining -= size; remaining -= size;
last_bump += size;
destination.write_all(&copy_buffer[0..size])?; destination.write_all(&copy_buffer[0..size])?;
if last_bump > BUMP_SIZE {
last_bump -= BUMP_SIZE;
if self.control_flag.get() == DownloadThreadControlFlag::Stop {
return Ok(false);
}
}
if remaining == 0 { if remaining == 0 {
break; break;
}; };
@ -131,6 +147,13 @@ impl<'a> DropDownloadPipeline<'a, Response, File> {
Ok(true) Ok(true)
} }
#[allow(dead_code)]
fn debug_skip_checksum(self) {
self.destination
.into_iter()
.for_each(|mut e| e.flush().unwrap());
}
fn finish(self) -> Result<Vec<Digest>, io::Error> { fn finish(self) -> Result<Vec<Digest>, io::Error> {
let checksums = self let checksums = self
.destination .destination
@ -153,6 +176,8 @@ pub fn download_game_bucket(
return Ok(false); return Ok(false);
} }
let start = Instant::now();
let header = generate_authorization_header(); let header = generate_authorization_header();
let url = generate_url(&["/api/v2/client/chunk"], &[]) let url = generate_url(&["/api/v2/client/chunk"], &[])
@ -172,7 +197,7 @@ pub fn download_game_bucket(
let raw_res = response.text().map_err(|e| { let raw_res = response.text().map_err(|e| {
ApplicationDownloadError::Communication(RemoteAccessError::FetchError(e.into())) ApplicationDownloadError::Communication(RemoteAccessError::FetchError(e.into()))
})?; })?;
info!("{}", raw_res); info!("{raw_res}");
if let Ok(err) = serde_json::from_str::<DropServerError>(&raw_res) { if let Ok(err) = serde_json::from_str::<DropServerError>(&raw_res) {
return Err(ApplicationDownloadError::Communication( return Err(ApplicationDownloadError::Communication(
RemoteAccessError::InvalidResponse(err), RemoteAccessError::InvalidResponse(err),
@ -195,10 +220,7 @@ pub fn download_game_bucket(
for (i, raw_length) in lengths.split(",").enumerate() { for (i, raw_length) in lengths.split(",").enumerate() {
let length = raw_length.parse::<usize>().unwrap_or(0); let length = raw_length.parse::<usize>().unwrap_or(0);
let Some(drop) = bucket.drops.get(i) else { let Some(drop) = bucket.drops.get(i) else {
warn!( warn!("invalid number of Content-Lengths recieved: {i}, {lengths}");
"invalid number of Content-Lengths recieved: {}, {}",
i, lengths
);
return Err(ApplicationDownloadError::DownloadError); return Err(ApplicationDownloadError::DownloadError);
}; };
if drop.length != length { if drop.length != length {
@ -210,6 +232,10 @@ pub fn download_game_bucket(
} }
} }
let timestep = start.elapsed().as_millis();
debug!("took {}ms to start downloading", timestep);
let mut pipeline = let mut pipeline =
DropDownloadPipeline::new(response, bucket.drops.clone(), control_flag, progress) DropDownloadPipeline::new(response, bucket.drops.clone(), control_flag, progress)
.map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?; .map_err(|e| ApplicationDownloadError::IoError(Arc::new(e)))?;

View File

@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
// Drops go in buckets // Drops go in buckets
pub struct DownloadDrop { pub struct DownloadDrop {
pub index: usize, pub index: usize,
@ -14,7 +14,7 @@ pub struct DownloadDrop {
pub permissions: u32, pub permissions: u32,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct DownloadBucket { pub struct DownloadBucket {
pub game_id: String, pub game_id: String,
pub version: String, pub version: String,

View File

@ -80,7 +80,13 @@ pub struct StatsUpdateEvent {
pub async fn fetch_library_logic( pub async fn fetch_library_logic(
state: tauri::State<'_, Mutex<AppState<'_>>>, state: tauri::State<'_, Mutex<AppState<'_>>>,
hard_fresh: Option<bool>,
) -> Result<Vec<Game>, RemoteAccessError> { ) -> Result<Vec<Game>, RemoteAccessError> {
let do_hard_refresh = hard_fresh.unwrap_or(false);
if !do_hard_refresh && let Ok(library) = get_cached_object("library") {
return Ok(library);
}
let client = DROP_CLIENT_ASYNC.clone(); let client = DROP_CLIENT_ASYNC.clone();
let response = generate_url(&["/api/v1/client/user/library"], &[])?; let response = generate_url(&["/api/v1/client/user/library"], &[])?;
let response = client let response = client
@ -142,6 +148,7 @@ pub async fn fetch_library_logic(
} }
pub async fn fetch_library_logic_offline( pub async fn fetch_library_logic_offline(
_state: tauri::State<'_, Mutex<AppState<'_>>>, _state: tauri::State<'_, Mutex<AppState<'_>>>,
_hard_refresh: Option<bool>,
) -> Result<Vec<Game>, RemoteAccessError> { ) -> Result<Vec<Game>, RemoteAccessError> {
let mut games: Vec<Game> = get_cached_object("library")?; let mut games: Vec<Game> = get_cached_object("library")?;
@ -521,9 +528,10 @@ pub fn push_game_update(
) { ) {
if let Some(GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }) = if let Some(GameDownloadStatus::Installed { .. } | GameDownloadStatus::SetupRequired { .. }) =
&status.0 &status.0
&& version.is_none() { && version.is_none()
panic!("pushed game for installed game that doesn't have version information"); {
} panic!("pushed game for installed game that doesn't have version information");
}
app_handle app_handle
.emit( .emit(

View File

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

View File

@ -11,6 +11,7 @@ mod games;
mod client; mod client;
mod download_manager; mod download_manager;
mod error; mod error;
mod playtime;
mod process; mod process;
mod remote; mod remote;
@ -46,6 +47,12 @@ use games::commands::{
use games::downloads::commands::download_game; use games::downloads::commands::download_game;
use games::library::{Game, update_game_configuration}; use games::library::{Game, update_game_configuration};
use log::{LevelFilter, debug, info, warn}; use log::{LevelFilter, debug, info, warn};
use playtime::manager::PlaytimeManager;
use playtime::commands::{
start_playtime_tracking, end_playtime_tracking, fetch_game_playtime,
fetch_all_playtime_stats, is_playtime_session_active, get_active_playtime_sessions,
cleanup_orphaned_playtime_sessions
};
use log4rs::Config; use log4rs::Config;
use log4rs::append::console::ConsoleAppender; use log4rs::append::console::ConsoleAppender;
use log4rs::append::file::FileAppender; use log4rs::append::file::FileAppender;
@ -65,7 +72,6 @@ use std::fs::File;
use std::io::Write; use std::io::Write;
use std::panic::PanicHookInfo; use std::panic::PanicHookInfo;
use std::path::Path; use std::path::Path;
use std::process::{Command, Stdio};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
@ -110,13 +116,7 @@ fn create_new_compat_info() -> Option<CompatInfo> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
return None; return None;
let has_umu_installed = Command::new(UMU_LAUNCHER_EXECUTABLE) let has_umu_installed = UMU_LAUNCHER_EXECUTABLE.is_some();
.stdout(Stdio::null())
.spawn();
if let Err(umu_error) = &has_umu_installed {
warn!("disabling windows support with error: {umu_error}");
}
let has_umu_installed = has_umu_installed.is_ok();
Some(CompatInfo { Some(CompatInfo {
umu_installed: has_umu_installed, umu_installed: has_umu_installed,
}) })
@ -134,7 +134,11 @@ pub struct AppState<'a> {
#[serde(skip_serializing)] #[serde(skip_serializing)]
process_manager: Arc<Mutex<ProcessManager<'a>>>, process_manager: Arc<Mutex<ProcessManager<'a>>>,
#[serde(skip_serializing)] #[serde(skip_serializing)]
playtime_manager: Arc<Mutex<PlaytimeManager>>,
#[serde(skip_serializing)]
compat_info: Option<CompatInfo>, compat_info: Option<CompatInfo>,
#[serde(skip_serializing)]
app_handle: AppHandle,
} }
async fn setup(handle: AppHandle) -> AppState<'static> { async fn setup(handle: AppHandle) -> AppState<'static> {
@ -171,6 +175,7 @@ async fn setup(handle: AppHandle) -> AppState<'static> {
let games = HashMap::new(); let games = HashMap::new();
let download_manager = Arc::new(DownloadManagerBuilder::build(handle.clone())); let download_manager = Arc::new(DownloadManagerBuilder::build(handle.clone()));
let process_manager = Arc::new(Mutex::new(ProcessManager::new(handle.clone()))); let process_manager = Arc::new(Mutex::new(ProcessManager::new(handle.clone())));
let playtime_manager = Arc::new(Mutex::new(PlaytimeManager::new(handle.clone())));
let compat_info = create_new_compat_info(); let compat_info = create_new_compat_info();
debug!("checking if database is set up"); debug!("checking if database is set up");
@ -185,7 +190,9 @@ async fn setup(handle: AppHandle) -> AppState<'static> {
games, games,
download_manager, download_manager,
process_manager, process_manager,
playtime_manager,
compat_info, compat_info,
app_handle: handle.clone(),
}; };
} }
@ -244,13 +251,20 @@ async fn setup(handle: AppHandle) -> AppState<'static> {
warn!("failed to sync autostart state: {e}"); warn!("failed to sync autostart state: {e}");
} }
// Clean up any orphaned playtime sessions
if let Err(e) = playtime_manager.lock().unwrap().cleanup_orphaned_sessions() {
warn!("failed to cleanup orphaned playtime sessions: {e}");
}
AppState { AppState {
status: app_status, status: app_status,
user, user,
games, games,
download_manager, download_manager,
process_manager, process_manager,
playtime_manager,
compat_info, compat_info,
app_handle: handle.clone(),
} }
} }
@ -341,7 +355,15 @@ pub fn run() {
kill_game, kill_game,
toggle_autostart, toggle_autostart,
get_autostart_enabled, get_autostart_enabled,
open_process_logs open_process_logs,
// Playtime tracking
start_playtime_tracking,
end_playtime_tracking,
fetch_game_playtime,
fetch_all_playtime_stats,
is_playtime_session_active,
get_active_playtime_sessions,
cleanup_orphaned_playtime_sessions
]) ])
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())

View File

@ -0,0 +1,95 @@
use std::collections::HashMap;
use tauri::State;
use std::sync::Mutex;
use crate::AppState;
use super::manager::PlaytimeStats;
use super::events::{push_playtime_update, push_session_start, push_session_end};
#[tauri::command]
pub fn start_playtime_tracking(
game_id: String,
state: State<'_, Mutex<AppState<'_>>>,
) -> Result<(), String> {
let state_lock = state.lock().unwrap();
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
match playtime_manager_lock.start_session(game_id.clone()) {
Ok(()) => {
push_session_start(&state_lock.app_handle, &game_id);
Ok(())
}
Err(e) => Err(e.to_string())
}
}
#[tauri::command]
pub fn end_playtime_tracking(
game_id: String,
state: State<'_, Mutex<AppState<'_>>>,
) -> Result<PlaytimeStats, String> {
let state_lock = state.lock().unwrap();
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
match playtime_manager_lock.end_session(game_id.clone()) {
Ok(stats) => {
push_session_end(&state_lock.app_handle, &game_id, &stats);
push_playtime_update(&state_lock.app_handle, &game_id, stats.clone(), false);
Ok(stats)
}
Err(e) => Err(e.to_string())
}
}
#[tauri::command]
pub fn fetch_game_playtime(
game_id: String,
state: State<'_, Mutex<AppState<'_>>>,
) -> Result<Option<PlaytimeStats>, String> {
let state_lock = state.lock().unwrap();
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
Ok(playtime_manager_lock.get_game_stats(&game_id))
}
#[tauri::command]
pub fn fetch_all_playtime_stats(
state: State<'_, Mutex<AppState<'_>>>,
) -> Result<HashMap<String, PlaytimeStats>, String> {
let state_lock = state.lock().unwrap();
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
Ok(playtime_manager_lock.get_all_stats())
}
#[tauri::command]
pub fn is_playtime_session_active(
game_id: String,
state: State<'_, Mutex<AppState<'_>>>,
) -> Result<bool, String> {
let state_lock = state.lock().unwrap();
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
Ok(playtime_manager_lock.is_session_active(&game_id))
}
#[tauri::command]
pub fn get_active_playtime_sessions(
state: State<'_, Mutex<AppState<'_>>>,
) -> Result<Vec<String>, String> {
let state_lock = state.lock().unwrap();
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
Ok(playtime_manager_lock.get_active_sessions())
}
#[tauri::command]
pub fn cleanup_orphaned_playtime_sessions(
state: State<'_, Mutex<AppState<'_>>>,
) -> Result<(), String> {
let state_lock = state.lock().unwrap();
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
playtime_manager_lock.cleanup_orphaned_sessions()
.map_err(|e| e.to_string())
}

View File

@ -0,0 +1,81 @@
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use log::warn;
use super::manager::PlaytimeStats;
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PlaytimeUpdateEvent {
pub game_id: String,
pub stats: PlaytimeStats,
pub is_active: bool,
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PlaytimeSessionStartEvent {
pub game_id: String,
pub start_time: std::time::SystemTime,
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PlaytimeSessionEndEvent {
pub game_id: String,
pub session_duration_seconds: u64,
pub total_playtime_seconds: u64,
pub session_count: u32,
}
/// Push a playtime update event to the frontend
pub fn push_playtime_update(app_handle: &AppHandle, game_id: &str, stats: PlaytimeStats, is_active: bool) {
let event = PlaytimeUpdateEvent {
game_id: game_id.to_string(),
stats,
is_active,
};
if let Err(e) = app_handle.emit(&format!("playtime_update/{}", game_id), &event) {
warn!("Failed to emit playtime update event for {}: {}", game_id, e);
}
// Also emit a general playtime update event for global listeners
if let Err(e) = app_handle.emit("playtime_update", &event) {
warn!("Failed to emit general playtime update event: {}", e);
}
}
/// Push a session start event to the frontend
pub fn push_session_start(app_handle: &AppHandle, game_id: &str) {
let event = PlaytimeSessionStartEvent {
game_id: game_id.to_string(),
start_time: std::time::SystemTime::now(),
};
if let Err(e) = app_handle.emit(&format!("playtime_session_start/{}", game_id), &event) {
warn!("Failed to emit session start event for {}: {}", game_id, e);
}
if let Err(e) = app_handle.emit("playtime_session_start", &event) {
warn!("Failed to emit general session start event: {}", e);
}
}
/// Push a session end event to the frontend
pub fn push_session_end(app_handle: &AppHandle, game_id: &str, stats: &PlaytimeStats) {
let event = PlaytimeSessionEndEvent {
game_id: game_id.to_string(),
session_duration_seconds: stats.current_session_duration.unwrap_or(0),
total_playtime_seconds: stats.total_playtime_seconds,
session_count: stats.session_count,
};
if let Err(e) = app_handle.emit(&format!("playtime_session_end/{}", game_id), &event) {
warn!("Failed to emit session end event for {}: {}", game_id, e);
}
if let Err(e) = app_handle.emit("playtime_session_end", &event) {
warn!("Failed to emit general session end event: {}", e);
}
}

View File

@ -0,0 +1,255 @@
use std::collections::HashMap;
use std::time::SystemTime;
use std::fmt;
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use crate::database::db::{borrow_db_checked, borrow_db_mut_checked};
use crate::database::models::data::{GamePlaytimeStats, PlaytimeSession};
use crate::error::process_error::ProcessError;
#[derive(Debug)]
pub enum PlaytimeError {
DatabaseError(String),
SessionNotFound(String),
SessionAlreadyActive(String),
InvalidGameId(String),
}
impl fmt::Display for PlaytimeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PlaytimeError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
PlaytimeError::SessionNotFound(game_id) => write!(f, "Session not found for game: {}", game_id),
PlaytimeError::SessionAlreadyActive(game_id) => write!(f, "Session already active for game: {}", game_id),
PlaytimeError::InvalidGameId(game_id) => write!(f, "Invalid game ID: {}", game_id),
}
}
}
impl std::error::Error for PlaytimeError {}
impl From<PlaytimeError> for ProcessError {
fn from(error: PlaytimeError) -> Self {
ProcessError::PlaytimeError(error.to_string())
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PlaytimeStats {
pub game_id: String,
pub total_playtime_seconds: u64,
pub session_count: u32,
pub first_played: SystemTime,
pub last_played: SystemTime,
pub average_session_length: u64,
pub current_session_duration: Option<u64>,
}
impl From<GamePlaytimeStats> for PlaytimeStats {
fn from(stats: GamePlaytimeStats) -> Self {
let average_length = stats.average_session_length();
Self {
game_id: stats.game_id,
total_playtime_seconds: stats.total_playtime_seconds,
session_count: stats.session_count,
first_played: stats.first_played,
last_played: stats.last_played,
average_session_length: average_length,
current_session_duration: None,
}
}
}
pub struct PlaytimeManager {
app_handle: AppHandle,
}
impl PlaytimeManager {
pub fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
/// Start tracking playtime for a game
pub fn start_session(&self, game_id: String) -> Result<(), PlaytimeError> {
debug!("Starting playtime session for game: {}", game_id);
let mut db_handle = borrow_db_mut_checked();
// Check if session is already active
if db_handle.playtime_data.active_sessions.contains_key(&game_id) {
warn!("Session already active for game: {}", game_id);
return Err(PlaytimeError::SessionAlreadyActive(game_id));
}
// Create new session
let session = PlaytimeSession::new(game_id.clone());
db_handle.playtime_data.active_sessions.insert(game_id.clone(), session);
debug!("Started playtime tracking for game: {}", game_id);
Ok(())
}
/// End tracking playtime for a game and update stats
pub fn end_session(&self, game_id: String) -> Result<PlaytimeStats, PlaytimeError> {
debug!("Ending playtime session for game: {}", game_id);
let mut db_handle = borrow_db_mut_checked();
// Get active session
let session = db_handle.playtime_data.active_sessions.remove(&game_id)
.ok_or_else(|| PlaytimeError::SessionNotFound(game_id.clone()))?;
let session_duration = session.duration().as_secs();
debug!("Session duration for {}: {} seconds", game_id, session_duration);
// Update or create game stats
let stats = db_handle.playtime_data.game_sessions
.entry(game_id.clone())
.or_insert_with(|| GamePlaytimeStats::new(game_id.clone()));
// Update stats
stats.total_playtime_seconds += session_duration;
stats.session_count += 1;
stats.last_played = SystemTime::now();
// If this is the first session, update first_played
if stats.session_count == 1 {
stats.first_played = session.start_time;
}
let result_stats = PlaytimeStats {
game_id: stats.game_id.clone(),
total_playtime_seconds: stats.total_playtime_seconds,
session_count: stats.session_count,
first_played: stats.first_played,
last_played: stats.last_played,
average_session_length: stats.average_session_length(),
current_session_duration: Some(session_duration),
};
debug!("Updated playtime stats for {}: {} total seconds, {} sessions",
game_id, stats.total_playtime_seconds, stats.session_count);
Ok(result_stats)
}
/// Get playtime stats for a specific game
pub fn get_game_stats(&self, game_id: &str) -> Option<PlaytimeStats> {
let db_handle = borrow_db_checked();
if let Some(stats) = db_handle.playtime_data.game_sessions.get(game_id) {
let mut playtime_stats: PlaytimeStats = stats.clone().into();
// If there's an active session, include current session duration
if let Some(session) = db_handle.playtime_data.active_sessions.get(game_id) {
playtime_stats.current_session_duration = Some(session.duration().as_secs());
}
Some(playtime_stats)
} else {
None
}
}
/// Get playtime stats for all games
pub fn get_all_stats(&self) -> HashMap<String, PlaytimeStats> {
let db_handle = borrow_db_checked();
let mut result = HashMap::new();
for (game_id, stats) in &db_handle.playtime_data.game_sessions {
let mut playtime_stats: PlaytimeStats = stats.clone().into();
// If there's an active session, include current session duration
if let Some(session) = db_handle.playtime_data.active_sessions.get(game_id) {
playtime_stats.current_session_duration = Some(session.duration().as_secs());
}
result.insert(game_id.clone(), playtime_stats);
}
result
}
/// Check if a game has an active session
pub fn is_session_active(&self, game_id: &str) -> bool {
let db_handle = borrow_db_checked();
db_handle.playtime_data.active_sessions.contains_key(game_id)
}
/// Get active sessions (for debugging/monitoring)
pub fn get_active_sessions(&self) -> Vec<String> {
let db_handle = borrow_db_checked();
db_handle.playtime_data.active_sessions.keys().cloned().collect()
}
/// Clean up any orphaned sessions (called on startup)
pub fn cleanup_orphaned_sessions(&self) -> Result<(), PlaytimeError> {
debug!("Cleaning up orphaned playtime sessions");
let mut db_handle = borrow_db_mut_checked();
let orphaned_sessions: Vec<String> = db_handle.playtime_data.active_sessions.keys().cloned().collect();
for game_id in orphaned_sessions {
warn!("Found orphaned session for game: {}, ending it", game_id);
if let Some(session) = db_handle.playtime_data.active_sessions.remove(&game_id) {
let session_duration = session.duration().as_secs();
// Only count sessions that lasted more than 5 seconds to avoid counting crashes
if session_duration > 5 {
let stats = db_handle.playtime_data.game_sessions
.entry(game_id.clone())
.or_insert_with(|| GamePlaytimeStats::new(game_id.clone()));
stats.total_playtime_seconds += session_duration;
stats.session_count += 1;
stats.last_played = SystemTime::now();
if stats.session_count == 1 {
stats.first_played = session.start_time;
}
debug!("Recovered orphaned session for {}: {} seconds", game_id, session_duration);
} else {
debug!("Discarded short orphaned session for {}: {} seconds", game_id, session_duration);
}
}
}
Ok(())
}
// Future server-side methods (ready for migration)
/// Start session with server sync (placeholder for future implementation)
#[allow(dead_code)]
pub async fn sync_session_start(&self, game_id: String) -> Result<(), PlaytimeError> {
// For now, just call local method
self.start_session(game_id)?;
// Future: Send to server
// let response = self.api_client.post("/api/v1/playtime/start")
// .json(&StartSessionRequest { game_id })
// .send().await?;
Ok(())
}
/// End session with server sync (placeholder for future implementation)
#[allow(dead_code)]
pub async fn sync_session_end(&self, game_id: String) -> Result<PlaytimeStats, PlaytimeError> {
// For now, just call local method
let stats = self.end_session(game_id)?;
// Future: Send to server
// let response = self.api_client.post("/api/v1/playtime/end")
// .json(&EndSessionRequest { game_id, duration: stats.current_session_duration })
// .send().await?;
Ok(stats)
}
}

View File

@ -0,0 +1,3 @@
pub mod commands;
pub mod events;
pub mod manager;

View File

@ -16,14 +16,28 @@ pub fn launch_game(
// download_type: DownloadType::Game, // download_type: DownloadType::Game,
//}; //};
match process_manager_lock.launch_process(id, &state_lock) { match process_manager_lock.launch_process(id.clone(), &state_lock) {
Ok(()) => {} Ok(()) => {
Err(e) => return Err(e), // Start playtime tracking after successful launch
drop(process_manager_lock);
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
if let Err(e) = playtime_manager_lock.start_session(id.clone()) {
log::warn!("Failed to start playtime tracking for {}: {}", id, e);
} else {
log::debug!("Started playtime tracking for game: {}", id);
crate::playtime::events::push_session_start(&state_lock.app_handle, &id);
}
drop(playtime_manager_lock);
}
Err(e) => {
drop(process_manager_lock);
drop(state_lock);
return Err(e);
}
} }
drop(process_manager_lock);
drop(state_lock); drop(state_lock);
Ok(()) Ok(())
} }
@ -33,6 +47,18 @@ pub fn kill_game(
state: tauri::State<'_, Mutex<AppState>>, state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), ProcessError> { ) -> Result<(), ProcessError> {
let state_lock = state.lock().unwrap(); let state_lock = state.lock().unwrap();
let mut process_manager_lock = state_lock.process_manager.lock().unwrap();
// End playtime tracking before killing the game
drop(process_manager_lock);
let playtime_manager_lock = state_lock.playtime_manager.lock().unwrap();
if let Ok(stats) = playtime_manager_lock.end_session(game_id.clone()) {
log::debug!("Ended playtime tracking for game: {} (manual kill)", game_id);
crate::playtime::events::push_session_end(&state_lock.app_handle, &game_id, &stats);
crate::playtime::events::push_playtime_update(&state_lock.app_handle, &game_id, stats, false);
}
drop(playtime_manager_lock);
let mut process_manager_lock = state_lock.process_manager.lock().unwrap(); let mut process_manager_lock = state_lock.process_manager.lock().unwrap();
process_manager_lock process_manager_lock
.kill_game(game_id) .kill_game(game_id)

View File

@ -1,4 +1,11 @@
use log::debug; use std::{
ffi::OsStr,
path::PathBuf,
process::{Command, Stdio},
sync::LazyLock,
};
use log::{debug, info};
use crate::{ use crate::{
AppState, AppState,
@ -24,7 +31,31 @@ impl ProcessHandler for NativeGameLauncher {
} }
} }
pub const UMU_LAUNCHER_EXECUTABLE: &str = "umu-run"; pub static UMU_LAUNCHER_EXECUTABLE: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
let x = get_umu_executable();
info!("{:?}", &x);
x
});
const UMU_BASE_LAUNCHER_EXECUTABLE: &str = "umu-run";
const UMU_INSTALL_DIRS: [&str; 4] = ["/app/share", "/use/local/share", "/usr/share", "/opt"];
fn get_umu_executable() -> Option<PathBuf> {
if check_executable_exists(UMU_BASE_LAUNCHER_EXECUTABLE) {
return Some(PathBuf::from(UMU_BASE_LAUNCHER_EXECUTABLE));
}
for dir in UMU_INSTALL_DIRS {
let p = PathBuf::from(dir).join(UMU_BASE_LAUNCHER_EXECUTABLE);
if check_executable_exists(&p) {
return Some(p);
}
}
None
}
fn check_executable_exists<P: AsRef<OsStr>>(exec: P) -> bool {
let has_umu_installed = Command::new(exec).stdout(Stdio::null()).output();
has_umu_installed.is_ok()
}
pub struct UMULauncher; pub struct UMULauncher;
impl ProcessHandler for UMULauncher { impl ProcessHandler for UMULauncher {
fn create_launch_process( fn create_launch_process(
@ -47,8 +78,8 @@ impl ProcessHandler for UMULauncher {
None => game_version.game_id.clone(), None => game_version.game_id.clone(),
}; };
format!( format!(
"GAMEID={game_id} {umu} \"{launch}\" {args}", "GAMEID={game_id} {umu:?} \"{launch}\" {args}",
umu = UMU_LAUNCHER_EXECUTABLE, umu = UMU_LAUNCHER_EXECUTABLE.as_ref().unwrap(),
launch = launch_command, launch = launch_command,
args = args.join(" ") args = args.join(" ")
) )
@ -80,7 +111,10 @@ impl ProcessHandler for AsahiMuvmLauncher {
game_version, game_version,
current_dir, current_dir,
); );
let mut args_cmd = umu_string.split("umu-run").collect::<Vec<&str>>().into_iter(); let mut args_cmd = umu_string
.split("umu-run")
.collect::<Vec<&str>>()
.into_iter();
let args = args_cmd.next().unwrap().trim(); let args = args_cmd.next().unwrap().trim();
let cmd = format!("umu-run{}", args_cmd.next().unwrap()); let cmd = format!("umu-run{}", args_cmd.next().unwrap());

View File

@ -29,6 +29,7 @@ use crate::{
}, },
error::process_error::ProcessError, error::process_error::ProcessError,
games::{library::push_game_update, state::GameStatusManager}, games::{library::push_game_update, state::GameStatusManager},
playtime::events::{push_session_end, push_playtime_update},
process::{ process::{
format::DropFormatArgs, format::DropFormatArgs,
process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher}, process_handlers::{AsahiMuvmLauncher, NativeGameLauncher, UMULauncher},
@ -394,6 +395,15 @@ impl ProcessManager<'_> {
let app_state = wait_thread_apphandle.state::<Mutex<AppState>>(); let app_state = wait_thread_apphandle.state::<Mutex<AppState>>();
let app_state_handle = app_state.lock().unwrap(); let app_state_handle = app_state.lock().unwrap();
// End playtime tracking before processing finish
let playtime_manager_lock = app_state_handle.playtime_manager.lock().unwrap();
if let Ok(stats) = playtime_manager_lock.end_session(wait_thread_game_id.id.clone()) {
debug!("Ended playtime tracking for game: {} (process finished)", wait_thread_game_id.id);
push_session_end(&app_state_handle.app_handle, &wait_thread_game_id.id, &stats);
push_playtime_update(&app_state_handle.app_handle, &wait_thread_game_id.id, stats, false);
}
drop(playtime_manager_lock);
let mut process_manager_handle = app_state_handle.process_manager.lock().unwrap(); let mut process_manager_handle = app_state_handle.process_manager.lock().unwrap();
process_manager_handle.on_process_finish(wait_thread_game_id.id, result); process_manager_handle.on_process_finish(wait_thread_game_id.id, result);

View File

@ -12,7 +12,7 @@ use crate::{
database::{ database::{
db::{borrow_db_checked, borrow_db_mut_checked}, db::{borrow_db_checked, borrow_db_mut_checked},
models::data::DatabaseAuth, models::data::DatabaseAuth,
}, error::{drop_server_error::DropServerError, remote_access_error::RemoteAccessError}, remote::{requests::make_authenticated_get, utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC}}, AppState, AppStatus, User }, error::{drop_server_error::DropServerError, remote_access_error::RemoteAccessError}, remote::{cache::clear_cached_object, requests::make_authenticated_get, utils::{DROP_CLIENT_ASYNC, DROP_CLIENT_SYNC}}, AppState, AppStatus, User
}; };
use super::{ use super::{
@ -159,6 +159,9 @@ pub async fn recieve_handshake(app: AppHandle, path: String) {
state_lock.status = app_status; state_lock.status = app_status;
state_lock.user = user; state_lock.user = user;
let _ = clear_cached_object("collections");
let _ = clear_cached_object("library");
drop(state_lock); drop(state_lock);
app.emit("auth/finished", ()).unwrap(); app.emit("auth/finished", ()).unwrap();

View File

@ -50,6 +50,12 @@ fn read_sync(base: &Path, key: &str) -> io::Result<Vec<u8>> {
Ok(file) Ok(file)
} }
fn delete_sync(base: &Path, key: &str) -> io::Result<()> {
let cache_path = get_cache_path(base, key);
std::fs::remove_file(cache_path)?;
Ok(())
}
pub fn cache_object<D: Encode>(key: &str, data: &D) -> Result<(), RemoteAccessError> { pub fn cache_object<D: Encode>(key: &str, data: &D) -> Result<(), RemoteAccessError> {
cache_object_db(key, data, &borrow_db_checked()) cache_object_db(key, data, &borrow_db_checked())
} }
@ -73,6 +79,17 @@ pub fn get_cached_object_db<D: DecodeOwned>(
bitcode::decode::<D>(&bytes).map_err(|e| RemoteAccessError::Cache(io::Error::other(e)))?; bitcode::decode::<D>(&bytes).map_err(|e| RemoteAccessError::Cache(io::Error::other(e)))?;
Ok(data) Ok(data)
} }
pub fn clear_cached_object(key: &str) -> Result<(), RemoteAccessError> {
clear_cached_object_db(key, &borrow_db_checked())
}
pub fn clear_cached_object_db(
key: &str,
db: &Database,
) -> Result<(), RemoteAccessError> {
delete_sync(&db.cache_dir, key).map_err(RemoteAccessError::Cache)?;
Ok(())
}
#[derive(Encode, Decode)] #[derive(Encode, Decode)]
pub struct ObjectCache { pub struct ObjectCache {
content_type: String, content_type: String,

View File

@ -130,7 +130,7 @@ pub fn auth_initiate_code(app: AppHandle) -> Result<String, RemoteAccessError> {
let code = auth_initiate_logic("code".to_string())?; let code = auth_initiate_logic("code".to_string())?;
let header_code = code.clone(); let header_code = code.clone();
println!("using code: {} to sign in", code); println!("using code: {code} to sign in");
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let load = async || -> Result<(), RemoteAccessError> { let load = async || -> Result<(), RemoteAccessError> {

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2.0.0", "$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "Drop Desktop Client", "productName": "Drop Desktop Client",
"version": "0.3.2", "version": "0.3.3",
"identifier": "dev.drop.client", "identifier": "dev.drop.client",
"build": { "build": {
"beforeDevCommand": "yarn --cwd main dev --port 1432", "beforeDevCommand": "yarn --cwd main dev --port 1432",