Compare commits

..

11 Commits

Author SHA1 Message Date
quexeky
4228e1797b chore(gamepads): Experimenting with gamepads and such implementations
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-27 21:42:08 +11:00
DecDuck
9af0d08875 fix(auth initiate): add better error message 2024-12-27 12:15:30 +11:00
quexeky
dcb1564568 chore: Ran cargo clippy
Signed-off-by: quexeky <git@quexeky.dev>
2024-12-27 11:58:37 +11:00
DecDuck
1f899ec349 feat(download ui): add speed and time remaining information
closes #7

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

View File

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

View File

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

27
composables/downloads.ts Normal file
View File

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

View File

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

View File

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

67
gamepad.ts Normal file
View File

@@ -0,0 +1,67 @@
import gameControl, { XBoxButton, Button, type GCGamepad } from 'esm-gamecontroller.js';
let mainGamepad: Ref<number | undefined> = ref(undefined);
let buttonIndex = 0;
const buttons = computed((): any[] => {
let as = document.getElementsByTagName('a');
let buttons = document.getElementsByTagName('button');
return [].concat(Array.from(as)).concat(Array.from(buttons));
})
const wrap = (num: number, min: number, max: number) => ((((num - min) % (max - min)) + (max - min)) % (max - min)) + min;
setInterval(() => {
mainGamepad.value = navigator.getGamepads().filter(g => g !== null)[0]?.axes[1];
console.log(navigator.getGamepads().filter(g => g !== null)[0]?.axes)
}, 100);
watch(mainGamepad, (v) => {
console.log(v)
if (!v || v == 0) return;
buttonIndex = wrap(buttonIndex + v > 0 ? 1 : -1, 0, buttons.value.length);
console.log(`Focusing ${buttonIndex}`)
console.log(buttons.value[buttonIndex]);
buttons.value[buttonIndex].focus()
})
/*
setInterval(() => {
console.log(gamepads[0]);
console.log(gamepads[0].checkStatus())
}, 1000);
*/
/*
window.ongamepadconnected = (e) => {
console.log("ongamepadconnected", e);
}
function selectButton(index: number) {
buttonIndex = (buttonIndex + index) % buttons.value.length;
console.log(`Selecting button ${buttonIndex}`);
buttons.value[buttonIndex].focus()
}
function processGamepads() {
while (true) {
console.log("Processing gamepads");
let gamepad = navigator.getGamepads()[0];
if (!gamepad) continue;
let direction = gamepad.axes[1];
if (direction > 0.1) {
selectButton(1)
console.log("Selecting button 1")
}
else if (direction < -0.1) {
selectButton(-1)
console.log("Selecting button -1")
}
}
}
*/

View File

@@ -14,15 +14,19 @@
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@maulingmonkey/gamepad": "^0.0.5",
"@nuxtjs/tailwindcss": "^6.12.2",
"@tauri-apps/api": ">=2.0.0",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-shell": ">=2.0.0",
"esm-gamecontroller.js": "^1.0.4",
"gamepad-events": "^0.7.0",
"markdown-it": "^14.1.0",
"moment": "^2.30.1",
"nuxt": "^3.13.0",
"scss": "^0.2.4",
"tauri-plugin-gamepad-api": "^0.0.5",
"vue": "latest",
"vue-router": "latest",
"vuedraggable": "^4.1.0"

View File

@@ -20,8 +20,9 @@
<div class="h-full flex flex-row gap-x-4 items-stretch">
<GameStatusButton
@install="() => installFlow()"
@play="() => play()"
@launch="() => launch()"
@queue="() => queue()"
@uninstall="() => uninstall()"
:status="status"
/>
<a
@@ -389,7 +390,7 @@ async function install() {
}
}
async function play() {
async function launch() {
try {
await invoke("launch_game", { gameId: game.value.id });
} catch (e) {
@@ -409,4 +410,8 @@ async function play() {
async function queue() {
router.push("/queue");
}
async function uninstall() {
await invoke("uninstall_game", {gameId: game.value.id});
}
</script>

View File

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

View File

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

View File

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

1
plugins.ts Normal file
View File

@@ -0,0 +1 @@
import 'tauri-plugin-gamepad-api'

195
src-tauri/Cargo.lock generated
View File

@@ -355,6 +355,12 @@ dependencies = [
"piper",
]
[[package]]
name = "boxcar"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f839cdf7e2d3198ac6ca003fd8ebc61715755f41c1cad15ff13df67531e00ed"
[[package]]
name = "brotli"
version = "7.0.0"
@@ -965,6 +971,7 @@ dependencies = [
name = "drop-app"
version = "0.1.0"
dependencies = [
"boxcar",
"chrono",
"directories",
"hex",
@@ -984,6 +991,8 @@ dependencies = [
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-gamepad",
"tauri-plugin-os",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tokio",
@@ -1061,6 +1070,26 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
[[package]]
name = "enum-iterator"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c280b9e6b3ae19e152d8e31cf47f18389781e119d4013a2a2bb0180e5facc635"
dependencies = [
"enum-iterator-derive",
]
[[package]]
name = "enum-iterator-derive"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.91",
]
[[package]]
name = "enumflags2"
version = "0.7.10"
@@ -1435,6 +1464,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
dependencies = [
"rustix",
"windows-targets 0.52.6",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -1457,6 +1496,40 @@ dependencies = [
"wasi 0.11.0+wasi-snapshot-preview1",
]
[[package]]
name = "gilrs"
version = "0.11.0"
source = "git+https://github.com/eugenehp/gilrs.git#cbcff6a4cd722a132bf2ca3c55fa9147775c9fe9"
dependencies = [
"fnv",
"gilrs-core",
"log",
"serde",
"uuid",
"vec_map",
]
[[package]]
name = "gilrs-core"
version = "0.6.0"
source = "git+https://github.com/eugenehp/gilrs.git#cbcff6a4cd722a132bf2ca3c55fa9147775c9fe9"
dependencies = [
"core-foundation 0.10.0",
"inotify",
"io-kit-sys",
"js-sys",
"libc",
"libudev-sys",
"log",
"nix 0.29.0",
"serde",
"uuid",
"vec_map",
"wasm-bindgen",
"web-sys",
"windows",
]
[[package]]
name = "gimli"
version = "0.31.1"
@@ -2022,6 +2095,26 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.6.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -2031,6 +2124,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "ipnet"
version = "2.10.1"
@@ -2235,6 +2338,16 @@ dependencies = [
"libc",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
@@ -2306,6 +2419,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -2468,6 +2590,18 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.6.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -2853,6 +2987,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb6651f4be5e39563c4fe5cc8326349eb99a25d805a3493f791d5bfd0269e430"
dependencies = [
"log",
"serde",
"windows-sys 0.52.0",
]
[[package]]
name = "os_pipe"
version = "1.2.1"
@@ -4107,6 +4252,15 @@ dependencies = [
"syn 2.0.91",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
@@ -4388,6 +4542,39 @@ dependencies = [
"uuid",
]
[[package]]
name = "tauri-plugin-gamepad"
version = "0.0.5"
source = "git+https://github.com/quexeky/tauri-plugin-gamepad.git#92cafc0aae66a3de01c252ae2ed599b329b0c686"
dependencies = [
"enum-iterator",
"gilrs",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.9",
"uuid",
]
[[package]]
name = "tauri-plugin-os"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dda2d571a9baf0664c1f2088db227e3072f9028602fafa885deade7547c3b738"
dependencies = [
"gethostname",
"log",
"os_info",
"serde",
"serde_json",
"serialize-to-javascript",
"sys-locale",
"tauri",
"tauri-plugin",
"thiserror 2.0.9",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.2.0"
@@ -5021,6 +5208,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version-compare"
version = "0.2.0"
@@ -5856,7 +6049,7 @@ dependencies = [
"futures-sink",
"futures-util",
"hex",
"nix",
"nix 0.27.1",
"ordered-stream",
"rand 0.8.5",
"serde",

View File

@@ -1,8 +1,8 @@
[package]
name = "drop-app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
description = "The client application for the open-source, self-hosted game distribution platform Drop"
authors = ["Drop OSS"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -41,12 +41,13 @@ http = "1.1.0"
urlencoding = "2.1.3"
md5 = "0.7.0"
chrono = "0.4.38"
tauri-plugin-os = "2"
boxcar = "0.2.7"
tauri-plugin-gamepad = {git = "https://github.com/quexeky/tauri-plugin-gamepad.git", version = "0.0.5"}
[dependencies.tauri]
version = "2.1.1"
features = [
"tray-icon"
]
features = ["tray-icon"]
[dependencies.tokio]

View File

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

View File

@@ -1,7 +1,6 @@
use std::{
env,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
use chrono::Utc;
@@ -13,7 +12,7 @@ use url::Url;
use crate::{
db::{DatabaseAuth, DatabaseImpls},
remote::RemoteAccessError,
remote::{DropServerError, RemoteAccessError},
AppState, AppStatus, User, DB,
};
@@ -78,7 +77,13 @@ pub fn fetch_user() -> Result<User, RemoteAccessError> {
.send()?;
if response.status() != 200 {
info!("Could not fetch user: {}", response.text().unwrap());
let data = response.json::<DropServerError>()?;
info!("Could not fetch user: {}", data.status_message);
if data.status_message == "Nonce expired" {
return Err(RemoteAccessError::OutOfSync);
}
return Err(RemoteAccessError::InvalidCodeError(0));
}
@@ -147,7 +152,7 @@ pub fn recieve_handshake(app: AppHandle, path: String) {
app.emit("auth/finished", ()).unwrap();
}
async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
let base_url = {
let db_lock = DB.borrow_data().unwrap();
Url::parse(&db_lock.base_url.clone())?
@@ -159,14 +164,17 @@ async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
platform: env::consts::OS.to_string(),
};
let client = reqwest::Client::new();
let response = client.post(endpoint.to_string()).json(&body).send().await?;
let client = reqwest::blocking::Client::new();
let response = client.post(endpoint.to_string()).json(&body).send()?;
if response.status() != 200 {
return Err(RemoteAccessError::InvalidRedirect);
let data = response.json::<DropServerError>()?;
info!("Could not start handshake: {}", data.status_message);
return Err(RemoteAccessError::HandshakeFailed(data.status_message));
}
let redir_url = response.text().await?;
let redir_url = response.text()?;
let complete_redir_url = base_url.join(&redir_url)?;
info!("opening web browser to continue authentication");
@@ -176,8 +184,8 @@ async fn auth_initiate_wrapper() -> Result<(), RemoteAccessError> {
}
#[tauri::command]
pub async fn auth_initiate<'a>() -> Result<(), String> {
let result = auth_initiate_wrapper().await;
pub fn auth_initiate<'a>() -> Result<(), String> {
let result = auth_initiate_wrapper();
if result.is_err() {
return Err(result.err().unwrap().to_string());
}
@@ -199,8 +207,10 @@ pub fn retry_connect(state: tauri::State<'_, Mutex<AppState>>) -> Result<(), ()>
pub fn setup() -> Result<(AppStatus, Option<User>), ()> {
let data = DB.borrow_data().unwrap();
let auth = data.auth.clone();
drop(data);
if data.auth.is_some() {
if auth.is_some() {
let user_result = fetch_user();
if user_result.is_err() {
let error = user_result.err().unwrap();
@@ -215,7 +225,5 @@ pub fn setup() -> Result<(AppStatus, Option<User>), ()> {
return Ok((AppStatus::SignedIn, Some(user_result.unwrap())));
}
drop(data);
Ok((AppStatus::SignedOut, None))
}

View File

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

View File

@@ -21,6 +21,11 @@ pub struct DatabaseAuth {
pub client_id: String,
}
pub struct GameStatusData {
version_name: String,
install_dir: String,
}
// Strings are version names for a particular game
#[derive(Serialize, Clone, Deserialize)]
#[serde(tag = "type")]
@@ -42,6 +47,7 @@ pub enum GameTransientStatus {
Downloading { version_name: String },
Uninstalling {},
Updating { version_name: String },
Running {},
}
#[derive(Serialize, Deserialize, Clone)]

View File

@@ -4,11 +4,9 @@ use crate::downloads::manifest::{DropDownloadContext, DropManifest};
use crate::downloads::progress_object::ProgressHandle;
use crate::remote::RemoteAccessError;
use crate::DB;
use core::time;
use log::{debug, error, info};
use rayon::ThreadPoolBuilder;
use serde::ser::{Error, SerializeMap};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fmt::{Display, Formatter};
use std::fs::{create_dir_all, File};
use std::io;
@@ -32,24 +30,24 @@ pub struct GameDownloadAgent {
pub version: String,
pub control_flag: DownloadThreadControl,
contexts: Vec<DropDownloadContext>,
completed_contexts: Mutex<Vec<usize>>,
completed_contexts: VecDeque<usize>,
pub manifest: Mutex<Option<DropManifest>>,
pub progress: Arc<ProgressObject>,
sender: Sender<DownloadManagerSignal>,
pub stored_manifest: StoredManifest,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum GameDownloadError {
Communication(RemoteAccessError),
Checksum,
Setup(SetupError),
Lock,
IoError(io::Error),
IoError(io::ErrorKind),
DownloadError,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum SetupError {
Context,
}
@@ -101,7 +99,7 @@ impl GameDownloadAgent {
control_flag,
manifest: Mutex::new(None),
contexts: Vec::new(),
completed_contexts: Mutex::new(Vec::new()),
completed_contexts: VecDeque::new(),
progress: Arc::new(ProgressObject::new(0, 0, sender.clone())),
sender,
stored_manifest,
@@ -219,12 +217,9 @@ impl GameDownloadAgent {
let base_path = Path::new(&self.stored_manifest.base_path);
create_dir_all(base_path).unwrap();
*self.completed_contexts.lock().unwrap() = self.stored_manifest.get_completed_contexts();
info!(
"Completed contexts: {:?}",
*self.completed_contexts.lock().unwrap()
);
self.completed_contexts.clear();
self.completed_contexts
.extend(self.stored_manifest.get_completed_contexts());
for (raw_path, chunk) in manifest {
let path = base_path.join(Path::new(&raw_path));
@@ -260,7 +255,7 @@ impl GameDownloadAgent {
Ok(())
}
pub fn run(&self) -> Result<(), ()> {
pub fn run(&mut self) -> Result<(), ()> {
info!("downloading game: {}", self.id);
const DOWNLOAD_MAX_THREADS: usize = 1;
@@ -269,56 +264,57 @@ impl GameDownloadAgent {
.build()
.unwrap();
let completed_indexes = Arc::new(Mutex::new(Vec::new()));
let completed_indexes = Arc::new(boxcar::Vec::new());
let completed_indexes_loop_arc = completed_indexes.clone();
pool.scope(move |scope| {
let completed_lock = self.completed_contexts.lock().unwrap();
pool.scope(|scope| {
for (index, context) in self.contexts.iter().enumerate() {
let completed_indexes = completed_indexes_loop_arc.clone();
let progress = self.progress.get(index); // Clone arcs
let progress_handle = ProgressHandle::new(progress, self.progress.clone());
// If we've done this one already, skip it
if completed_lock.contains(&index) {
if self.completed_contexts.contains(&index) {
progress_handle.add(context.length);
continue;
}
let context = context.clone();
let control_flag = self.control_flag.clone(); // Clone arcs
let completed_indexes_ref = completed_indexes_loop_arc.clone();
let sender = self.sender.clone();
scope.spawn(move |_| {
match download_game_chunk(context.clone(), control_flag, progress_handle) {
Ok(res) => {
if res {
let mut lock = completed_indexes_ref.lock().unwrap();
lock.push(index);
completed_indexes.push(index);
}
}
Err(e) => {
error!("GameDownloadError: {}", e);
self.sender.send(DownloadManagerSignal::Error(e)).unwrap();
error!("{}", e);
sender.send(DownloadManagerSignal::Error(e)).unwrap();
}
}
});
}
});
let newly_completed = completed_indexes.to_owned();
let completed_lock_len = {
let mut completed_lock = self.completed_contexts.lock().unwrap();
let newly_completed_lock = completed_indexes.lock().unwrap();
for (item, item_ref) in newly_completed.iter() {
self.completed_contexts.push_front(item);
}
completed_lock.extend(newly_completed_lock.iter());
completed_lock.len()
self.completed_contexts.len()
};
// If we're not out of contexts, we're not done, so we don't fire completed
if completed_lock_len != self.contexts.len() {
info!("da for {} exited without completing", self.id.clone());
self.stored_manifest
.set_completed_contexts(&self.completed_contexts);
.set_completed_contexts(&self.completed_contexts.clone().into());
info!("Setting completed contexts");
self.stored_manifest.write();
info!("Wrote completed contexts");

View File

@@ -6,14 +6,11 @@ use crate::DB;
use log::warn;
use md5::{Context, Digest};
use reqwest::blocking::Response;
use tauri::utils::acl::Permission;
use std::fs::{set_permissions, Permissions};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::thread::sleep;
use std::time::Duration;
use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Seek, SeekFrom, Write},
@@ -186,7 +183,9 @@ pub fn download_game_chunk(
content_length.unwrap().try_into().unwrap(),
);
let completed = pipeline.copy().map_err(GameDownloadError::IoError)?;
let completed = pipeline
.copy()
.map_err(|e| GameDownloadError::IoError(e.kind()))?;
if !completed {
return Ok(false);
};

View File

@@ -40,9 +40,14 @@ pub enum DownloadManagerSignal {
/// Any error which occurs in the agent
Error(GameDownloadError),
/// Pushes UI update
Update,
UpdateUIQueue,
UpdateUIStats(usize, usize), //kb/s and seconds
/// Uninstall game
/// Takes game ID
Uninstall(String),
}
#[derive(Debug, Clone)]
pub enum DownloadManagerStatus {
Downloading,
Paused,
@@ -51,6 +56,15 @@ pub enum DownloadManagerStatus {
Finished,
}
impl Serialize for DownloadManagerStatus {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&format!["{:?}", self])
}
}
#[derive(Serialize, Clone)]
pub enum GameDownloadStatus {
Queued,
@@ -142,7 +156,7 @@ impl DownloadManager {
let to_move = queue.remove(current_index).unwrap();
queue.insert(new_index, to_move);
self.command_sender
.send(DownloadManagerSignal::Update)
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
pub fn cancel(&self, game_id: String) {
@@ -174,7 +188,7 @@ impl DownloadManager {
self.command_sender.send(DownloadManagerSignal::Go).unwrap();
}
self.command_sender
.send(DownloadManagerSignal::Update)
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
pub fn pause_downloads(&self) {
@@ -191,6 +205,11 @@ impl DownloadManager {
.unwrap();
self.terminator.join()
}
pub fn uninstall_game(&self, game_id: String) {
self.command_sender
.send(DownloadManagerSignal::Uninstall(game_id))
.unwrap();
}
}
/// Takes in the locked value from .edit() and attempts to

View File

@@ -1,5 +1,6 @@
use std::{
collections::HashMap,
fs::remove_dir_all,
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex, RwLockWriteGuard,
@@ -12,7 +13,10 @@ use tauri::{AppHandle, Emitter};
use crate::{
db::{Database, GameStatus, GameTransientStatus},
library::{on_game_complete, GameUpdateEvent, QueueUpdateEvent, QueueUpdateEventQueueData},
library::{
on_game_complete, push_game_update, QueueUpdateEvent,
QueueUpdateEventQueueData, StatsUpdateEvent,
},
state::GameStatusManager,
DB,
};
@@ -108,7 +112,7 @@ impl DownloadManagerBuilder {
DownloadManager::new(terminator, queue, active_progress, command_sender)
}
fn set_game_status<F: FnOnce(&mut RwLockWriteGuard<'_, Database>, &String) -> ()>(
fn set_game_status<F: FnOnce(&mut RwLockWriteGuard<'_, Database>, &String)>(
&self,
id: String,
setter: F,
@@ -120,18 +124,16 @@ impl DownloadManagerBuilder {
let status = GameStatusManager::fetch_state(&id);
self.app_handle
.emit(
&format!("update_game/{}", id),
GameUpdateEvent {
game_id: id,
status,
},
)
.unwrap();
push_game_update(&self.app_handle, id, status);
}
fn push_manager_update(&self) {
fn push_ui_stats_update(&self, kbs: usize, time: usize) {
let event_data = StatsUpdateEvent { speed: kbs, time };
self.app_handle.emit("update_stats", event_data).unwrap();
}
fn push_ui_queue_update(&self) {
let queue = self.download_queue.read();
let queue_objs: Vec<QueueUpdateEventQueueData> = queue
.iter()
@@ -142,7 +144,14 @@ impl DownloadManagerBuilder {
})
.collect();
let event_data = QueueUpdateEvent { queue: queue_objs };
let status_handle = self.status.lock().unwrap();
let status = status_handle.clone();
drop(status_handle);
let event_data = QueueUpdateEvent {
queue: queue_objs,
status,
};
self.app_handle.emit("update_queue", event_data).unwrap();
}
@@ -159,9 +168,7 @@ impl DownloadManagerBuilder {
drop(download_thread_lock);
}
fn sync_download_agent(&self) {}
fn remove_and_cleanup_game(&mut self, game_id: &String) -> Arc<Mutex<GameDownloadAgent>> {
fn remove_and_cleanup_front_game(&mut self, game_id: &String) -> Arc<Mutex<GameDownloadAgent>> {
self.download_queue.pop_front();
let download_agent = self.download_agent_registry.remove(game_id).unwrap();
self.cleanup_current_download();
@@ -206,40 +213,117 @@ impl DownloadManagerBuilder {
DownloadManagerSignal::Cancel => {
self.manage_cancel_signal();
}
DownloadManagerSignal::Update => {
self.push_manager_update();
DownloadManagerSignal::UpdateUIQueue => {
self.push_ui_queue_update();
}
DownloadManagerSignal::UpdateUIStats(kbs, time) => {
self.push_ui_stats_update(kbs, time);
}
DownloadManagerSignal::Finish => {
self.stop_and_wait_current_download();
return Ok(());
}
DownloadManagerSignal::Remove(game_id) => {
self.manage_remove_game(game_id);
self.manage_remove_game_queue(game_id);
}
DownloadManagerSignal::Uninstall(game_id) => {
self.uninstall_game(game_id);
}
};
}
}
fn manage_remove_game(&mut self, game_id: String) {
fn uninstall_game(&mut self, game_id: String) {
// Removes the game if it's in the queue
self.manage_remove_game_queue(game_id.clone());
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle
.games
.transient_statuses
.entry(game_id.clone())
.and_modify(|v| *v = GameTransientStatus::Uninstalling {});
push_game_update(
&self.app_handle,
game_id.clone(),
(None, Some(GameTransientStatus::Uninstalling {})),
);
let previous_state = db_handle.games.statuses.get(&game_id).cloned();
if previous_state.is_none() {
info!("uninstall job doesn't have previous state, failing silently");
return;
}
let previous_state = previous_state.unwrap();
if let Some((version_name, install_dir)) = match previous_state {
GameStatus::Installed {
version_name,
install_dir,
} => Some((version_name, install_dir)),
GameStatus::SetupRequired {
version_name,
install_dir,
} => Some((version_name, install_dir)),
_ => None,
} {
db_handle
.games
.transient_statuses
.entry(game_id.clone())
.and_modify(|v| *v = GameTransientStatus::Uninstalling {});
drop(db_handle);
let sender = self.sender.clone();
let app_handle = self.app_handle.clone();
spawn(move || match remove_dir_all(install_dir) {
Err(e) => {
sender
.send(DownloadManagerSignal::Error(GameDownloadError::IoError(
e.kind(),
)))
.unwrap();
}
Ok(_) => {
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle.games.transient_statuses.remove(&game_id);
db_handle
.games
.statuses
.entry(game_id.clone())
.and_modify(|e| *e = GameStatus::Remote {});
drop(db_handle);
DB.save().unwrap();
info!("uninstalled {}", game_id);
push_game_update(&app_handle, game_id, (Some(GameStatus::Remote {}), None));
}
});
}
}
fn manage_remove_game_queue(&mut self, game_id: String) {
if let Some(current_download) = &self.current_download_agent {
if current_download.id == game_id {
self.manage_cancel_signal();
}
}
let index = self.download_queue.get_by_id(game_id.clone()).unwrap();
let mut queue_handle = self.download_queue.edit();
queue_handle.remove(index);
self.set_game_status(game_id, |db_handle, id| {
db_handle.games.transient_statuses.remove(id);
});
drop(queue_handle);
let index = self.download_queue.get_by_id(game_id.clone());
if let Some(index) = index {
let mut queue_handle = self.download_queue.edit();
queue_handle.remove(index);
self.set_game_status(game_id, |db_handle, id| {
db_handle.games.transient_statuses.remove(id);
});
drop(queue_handle);
}
if self.current_download_agent.is_none() {
self.manage_go_signal();
}
self.push_manager_update();
self.push_ui_queue_update();
}
fn manage_stop_signal(&mut self) {
@@ -256,11 +340,16 @@ impl DownloadManagerBuilder {
// When if let chains are stabilised, combine these two statements
if interface.id == game_id {
info!("Popping consumed data");
let download_agent = self.remove_and_cleanup_game(&game_id);
let download_agent = self.remove_and_cleanup_front_game(&game_id);
let download_agent_lock = download_agent.lock().unwrap();
let version = download_agent_lock.version.clone();
let install_dir = download_agent_lock.stored_manifest.base_path.clone().to_string_lossy().to_string();
let install_dir = download_agent_lock
.stored_manifest
.base_path
.clone()
.to_string_lossy()
.to_string();
drop(download_agent_lock);
@@ -275,12 +364,30 @@ impl DownloadManagerBuilder {
}
}
}
self.sender.send(DownloadManagerSignal::Update).unwrap();
self.sender
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
self.sender.send(DownloadManagerSignal::Go).unwrap();
}
fn manage_queue_signal(&mut self, id: String, version: String, target_download_dir: usize) {
info!("Got signal Queue");
if let Some(index) = self.download_queue.get_by_id(id.clone()) {
// Should always give us a value
if let Some(download_agent) = self.download_agent_registry.get(&id) {
let download_agent_handle = download_agent.lock().unwrap();
if download_agent_handle.version == version {
info!("game with same version already queued, skipping");
return;
}
// If it's not the same, we want to cancel the current one, and then add the new one
drop(download_agent_handle);
self.manage_remove_game_queue(id.clone());
}
}
let download_agent = Arc::new(Mutex::new(GameDownloadAgent::new(
id.clone(),
version,
@@ -309,7 +416,9 @@ impl DownloadManagerBuilder {
GameTransientStatus::Downloading { version_name },
);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
self.sender
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
fn manage_go_signal(&mut self) {
@@ -386,12 +495,19 @@ impl DownloadManagerBuilder {
);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
self.sender
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
fn manage_error_signal(&mut self, error: GameDownloadError) {
let current_status = self.current_download_agent.clone().unwrap();
self.remove_and_cleanup_game(&current_status.id); // Remove all the locks and shit
self.stop_and_wait_current_download();
self.remove_and_cleanup_front_game(&current_status.id); // Remove all the locks and shit, and remove from queue
self.app_handle
.emit("download_error", error.to_string())
.unwrap();
let mut lock = current_status.status.lock().unwrap();
*lock = GameDownloadStatus::Error;
@@ -402,7 +518,9 @@ impl DownloadManagerBuilder {
db_handle.games.transient_statuses.remove(id);
});
self.sender.send(DownloadManagerSignal::Update).unwrap();
self.sender
.send(DownloadManagerSignal::UpdateUIQueue)
.unwrap();
}
fn manage_cancel_signal(&mut self) {
self.stop_and_wait_current_download();

View File

@@ -7,4 +7,4 @@ mod download_thread_control_flag;
mod manifest;
mod progress_object;
pub mod queue;
mod stored_manifest;
mod stored_manifest;

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ use urlencoding::encode;
use crate::db::DatabaseImpls;
use crate::db::GameVersion;
use crate::db::{GameStatus, GameTransientStatus};
use crate::downloads::download_manager::GameDownloadStatus;
use crate::db::GameStatus;
use crate::downloads::download_manager::{DownloadManagerStatus, GameDownloadStatus};
use crate::process::process_manager::Platform;
use crate::remote::RemoteAccessError;
use crate::state::{GameStatusManager, GameStatusWithTransient};
@@ -37,7 +37,7 @@ pub struct Game {
#[derive(serde::Serialize, Clone)]
pub struct GameUpdateEvent {
pub game_id: String,
pub status: (Option<GameStatus>, Option<GameTransientStatus>),
pub status: GameStatusWithTransient,
}
#[derive(Serialize, Clone)]
@@ -50,6 +50,13 @@ pub struct QueueUpdateEventQueueData {
#[derive(serde::Serialize, Clone)]
pub struct QueueUpdateEvent {
pub queue: Vec<QueueUpdateEventQueueData>,
pub status: DownloadManagerStatus,
}
#[derive(serde::Serialize, Clone)]
pub struct StatsUpdateEvent {
pub speed: usize,
pub time: usize,
}
// Game version with some fields missing and size information
@@ -232,6 +239,30 @@ pub fn fetch_game_verion_options<'a>(
fetch_game_verion_options_logic(game_id, state).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn uninstall_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let state_lock = state.lock().unwrap();
state_lock.download_manager.uninstall_game(game_id);
drop(state_lock);
Ok(())
}
pub fn push_game_update(app_handle: &AppHandle, id: String, status: GameStatusWithTransient) {
app_handle
.emit(
&format!("update_game/{}", id),
GameUpdateEvent {
game_id: id,
status,
},
)
.unwrap();
}
pub fn on_game_complete(
game_id: String,
version_name: String,

View File

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

View File

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

View File

@@ -3,11 +3,14 @@ use std::sync::Mutex;
use crate::AppState;
#[tauri::command]
pub fn launch_game(game_id: String, state: tauri::State<'_, Mutex<AppState>>) -> Result<(), String> {
pub fn launch_game(
game_id: String,
state: tauri::State<'_, Mutex<AppState>>,
) -> Result<(), String> {
let state_lock = state.lock().unwrap();
let mut process_manager_lock = state_lock.process_manager.lock().unwrap();
process_manager_lock.launch_game(game_id)?;
process_manager_lock.launch_process(game_id)?;
drop(process_manager_lock);
drop(state_lock);

View File

@@ -1,28 +1,33 @@
use std::{
collections::HashMap,
fs::{File, OpenOptions},
io::{Stdout, Write},
path::{Path, PathBuf},
process::{Child, Command},
sync::LazyLock,
process::{Child, Command, ExitStatus},
sync::{Arc, Mutex},
thread::spawn,
};
use log::info;
use log::{info, warn};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::{
db::{GameStatus, DATA_ROOT_DIR},
DB,
db::{GameStatus, GameTransientStatus, DATA_ROOT_DIR},
library::push_game_update,
state::GameStatusManager,
AppState, DB,
};
pub struct ProcessManager {
pub struct ProcessManager<'a> {
current_platform: Platform,
log_output_dir: PathBuf,
processes: HashMap<String, Child>,
processes: HashMap<String, Arc<Mutex<Child>>>,
app_handle: AppHandle,
game_launchers: HashMap<(Platform, Platform), &'a (dyn ProcessHandler + Sync + Send + 'static)>,
}
impl ProcessManager {
pub fn new() -> Self {
impl ProcessManager<'_> {
pub fn new(app_handle: AppHandle) -> Self {
let root_dir_lock = DATA_ROOT_DIR.lock().unwrap();
let log_output_dir = root_dir_lock.join("logs");
drop(root_dir_lock);
@@ -34,12 +39,28 @@ impl ProcessManager {
Platform::Linux
},
app_handle,
processes: HashMap::new(),
log_output_dir,
game_launchers: HashMap::from([
// Current platform to target platform
(
(Platform::Windows, Platform::Windows),
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Linux, Platform::Linux),
&NativeGameLauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
(
(Platform::Linux, Platform::Windows),
&UMULauncher {} as &(dyn ProcessHandler + Sync + Send + 'static),
),
]),
}
}
fn process_command(&self, install_dir: &String, raw_command: String) -> (String, Vec<String>) {
fn process_command(&self, install_dir: &String, raw_command: String) -> (PathBuf, Vec<String>) {
let command_components = raw_command.split(" ").collect::<Vec<&str>>();
let root = command_components[0].to_string();
@@ -47,41 +68,90 @@ impl ProcessManager {
let absolute_exe = install_dir.join(root);
let args = command_components[1..]
.into_iter()
.iter()
.map(|v| v.to_string())
.collect();
(absolute_exe.to_str().unwrap().to_owned(), args)
(absolute_exe, args)
}
fn on_process_finish(&mut self, game_id: String, result: Result<ExitStatus, std::io::Error>) {
if !self.processes.contains_key(&game_id) {
warn!("process on_finish was called, but game_id is no longer valid. finished with result: {:?}", result);
return;
}
info!("process for {} exited with {:?}", game_id, result);
self.processes.remove(&game_id);
let mut db_handle = DB.borrow_data_mut().unwrap();
db_handle.games.transient_statuses.remove(&game_id);
let current_state = db_handle.games.statuses.get(&game_id).cloned();
if let Some(saved_state) = current_state {
if let GameStatus::SetupRequired {
version_name,
install_dir,
} = saved_state {
if let Ok(exit_code) = result {
if exit_code.success() {
db_handle.games.statuses.insert(
game_id.clone(),
GameStatus::Installed {
version_name: version_name.to_string(),
install_dir: install_dir.to_string(),
},
);
}
}
}
}
drop(db_handle);
let status = GameStatusManager::fetch_state(&game_id);
push_game_update(&self.app_handle, game_id.clone(), status);
// TODO better management
}
pub fn valid_platform(&self, platform: &Platform) -> Result<bool, String> {
let current = &self.current_platform;
let valid_platforms = PROCESS_COMPATABILITY_MATRIX
.get(current)
.ok_or("Incomplete platform compatability matrix.")?;
Ok(valid_platforms.contains(platform))
Ok(self
.game_launchers
.contains_key(&(current.clone(), platform.clone())))
}
pub fn launch_game(&mut self, game_id: String) -> Result<(), String> {
pub fn launch_process(&mut self, game_id: String) -> Result<(), String> {
if self.processes.contains_key(&game_id) {
return Err("Game or setup is already running.".to_owned());
}
let db_lock = DB.borrow_data().unwrap();
let mut db_lock = DB.borrow_data_mut().unwrap();
let game_status = db_lock
.games
.statuses
.get(&game_id)
.ok_or("Game not installed")?;
let GameStatus::Installed {
version_name,
install_dir,
} = game_status
else {
return Err("Game not installed.".to_owned());
let status_metadata: Option<(&String, &String)> = match game_status {
GameStatus::Installed {
version_name,
install_dir,
} => Some((version_name, install_dir)),
GameStatus::SetupRequired {
version_name,
install_dir,
} => Some((version_name, install_dir)),
_ => None,
};
if status_metadata.is_none() {
return Err("Game has not been downloaded.".to_owned());
}
let (version_name, install_dir) = status_metadata.unwrap();
let game_version = db_lock
.games
.versions
@@ -90,13 +160,30 @@ impl ProcessManager {
.get(version_name)
.ok_or("Invalid version name".to_owned())?;
let (command, args) =
self.process_command(install_dir, game_version.launch_command.clone());
let raw_command: String = match game_status {
GameStatus::Installed {
version_name: _,
install_dir: _,
} => game_version.launch_command.clone(),
GameStatus::SetupRequired {
version_name: _,
install_dir: _,
} => game_version.setup_command.clone(),
_ => panic!("unreachable code"),
};
info!("launching process {} in {}", command, install_dir);
let (command, args) = self.process_command(install_dir, raw_command);
let target_current_dir = command.parent().unwrap().to_str().unwrap();
info!(
"launching process {} in {}",
command.to_str().unwrap(),
target_current_dir
);
let current_time = chrono::offset::Local::now();
let mut log_file = OpenOptions::new()
let log_file = OpenOptions::new()
.write(true)
.truncate(true)
.read(true)
@@ -107,46 +194,132 @@ impl ProcessManager {
)
.map_err(|v| v.to_string())?;
let mut error_file = OpenOptions::new()
let error_file = OpenOptions::new()
.write(true)
.truncate(true)
.read(true)
.create(true)
.open(
self.log_output_dir
.join(format!("{}-{}-error.log", game_id, current_time.timestamp())),
)
.open(self.log_output_dir.join(format!(
"{}-{}-error.log",
game_id,
current_time.timestamp()
)))
.map_err(|v| v.to_string())?;
info!("opened log file for {}", command);
let current_platform = self.current_platform.clone();
let target_platform = game_version.platform.clone();
let launch_process = Command::new(command)
.current_dir(install_dir)
.stdout(log_file)
.stderr(error_file)
.args(args)
.spawn()
.map_err(|v| v.to_string())?;
let game_launcher = self
.game_launchers
.get(&(current_platform, target_platform))
.ok_or("Invalid version for this platform.")
.map_err(|e| e.to_string())?;
self.processes.insert(game_id, launch_process);
let launch_process = game_launcher.launch_process(
&game_id,
version_name,
command.to_str().unwrap().to_owned(),
args,
&target_current_dir.to_string(),
log_file,
error_file,
)?;
let launch_process_handle = Arc::new(Mutex::new(launch_process));
db_lock
.games
.transient_statuses
.insert(game_id.clone(), GameTransientStatus::Running {});
push_game_update(
&self.app_handle,
game_id.clone(),
(None, Some(GameTransientStatus::Running {})),
);
let wait_thread_handle = launch_process_handle.clone();
let wait_thread_apphandle = self.app_handle.clone();
let wait_thread_game_id = game_id.clone();
spawn(move || {
let mut child_handle = wait_thread_handle.lock().unwrap();
let result: Result<ExitStatus, std::io::Error> = child_handle.wait();
let app_state = wait_thread_apphandle.state::<Mutex<AppState>>();
let app_state_handle = app_state.lock().unwrap();
let mut process_manager_handle = app_state_handle.process_manager.lock().unwrap();
process_manager_handle.on_process_finish(wait_thread_game_id, result);
// As everything goes out of scope, they should get dropped
// But just to explicit about it
drop(process_manager_handle);
drop(app_state_handle);
drop(child_handle);
});
self.processes.insert(game_id, launch_process_handle);
info!("finished spawning process");
Ok(())
}
}
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone)]
#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Debug)]
pub enum Platform {
Windows,
Linux,
}
pub type ProcessCompatabilityMatrix = HashMap<Platform, Vec<Platform>>;
pub static PROCESS_COMPATABILITY_MATRIX: LazyLock<ProcessCompatabilityMatrix> =
LazyLock::new(|| {
let mut matrix: ProcessCompatabilityMatrix = HashMap::new();
pub trait ProcessHandler: Send + 'static {
fn launch_process(
&self,
game_id: &String,
version_name: &String,
command: String,
args: Vec<String>,
current_dir: &String,
log_file: File,
error_file: File,
) -> Result<Child, String>;
}
matrix.insert(Platform::Windows, vec![Platform::Windows]);
matrix.insert(Platform::Linux, vec![Platform::Linux]); // TODO: add Proton support
struct NativeGameLauncher;
impl ProcessHandler for NativeGameLauncher {
fn launch_process(
&self,
game_id: &String,
version_name: &String,
command: String,
args: Vec<String>,
current_dir: &String,
log_file: File,
error_file: File,
) -> Result<Child, String> {
Command::new(command)
.current_dir(current_dir)
.stdout(log_file)
.stderr(error_file)
.args(args)
.spawn()
.map_err(|v| v.to_string())
}
}
return matrix;
});
struct UMULauncher;
impl ProcessHandler for UMULauncher {
fn launch_process(
&self,
game_id: &String,
version_name: &String,
command: String,
args: Vec<String>,
current_dir: &String,
log_file: File,
error_file: File,
) -> Result<Child, String> {
todo!()
}
}

View File

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

View File

@@ -1,14 +1,10 @@
use std::collections::HashMap;
use crate::{
db::{GameStatus, GameTransientStatus},
DB,
};
pub type GameStatusWithTransient = (
Option<GameStatus>,
Option<GameTransientStatus>,
);
pub type GameStatusWithTransient = (Option<GameStatus>, Option<GameTransientStatus>);
pub struct GameStatusManager {}
impl GameStatusManager {
@@ -26,6 +22,6 @@ impl GameStatusManager {
return (offline_state, None);
}
return (None, None);
(None, None)
}
}

View File

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

View File

@@ -52,6 +52,7 @@ export enum GameStatusEnum {
Updating = "Updating",
Uninstalling = "Uninstalling",
SetupRequired = "SetupRequired",
Running = "Running"
}
export type GameStatus = {

View File

@@ -773,6 +773,11 @@
semver "^7.3.5"
tar "^6.1.11"
"@maulingmonkey/gamepad@^0.0.5":
version "0.0.5"
resolved "https://registry.yarnpkg.com/@maulingmonkey/gamepad/-/gamepad-0.0.5.tgz#50220895cc310f66f0678e07fa1e17959332ac20"
integrity sha512-dokZlauyTVAW2UEFC8T/L+1Oc7oK+vgakjfa4JZQPxkSoCTf7e3l0zV3E/Gys3Ps43Mmc8mnX+7d1ICTyD5pcg==
"@netlify/functions@^2.8.0":
version "2.8.2"
resolved "https://registry.yarnpkg.com/@netlify/functions/-/functions-2.8.2.tgz#653395b901a74a6189e913a089f9cb90083ca6ce"
@@ -1307,6 +1312,11 @@
dependencies:
"@tanstack/virtual-core" "3.10.8"
"@tauri-apps/api@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.1.1.tgz#77d4ddb683d31072de4e6a47c8613d9db011652b"
integrity sha512-fzUfFFKo4lknXGJq8qrCidkUcKcH2UHhfaaCNt4GzgzGaW2iS26uFOg4tS3H4P8D6ZEeUxtiD5z0nwFF0UN30A==
"@tauri-apps/api@>=2.0.0", "@tauri-apps/api@^2.0.0":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.0.1.tgz#dc49d899fb873b96ee1d46a171384625ba5ad404"
@@ -1392,6 +1402,13 @@
dependencies:
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-os@~2":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.2.0.tgz#ef5511269f59c0ccc580a9d09600034cfaa9743b"
integrity sha512-HszbCdbisMlu5QhCNAN8YIWyz2v33abAWha6+uvV2CKX8P5VSct/y+kEe22JeyqrxCnWlQ3DRx7s49Byg7/0EA==
dependencies:
"@tauri-apps/api" "^2.0.0"
"@tauri-apps/plugin-shell@>=2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.0.0.tgz#b6fc88ab070fd5f620e46405715779aa44eb8428"
@@ -2769,6 +2786,11 @@ escape-string-regexp@^5.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
esm-gamecontroller.js@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/esm-gamecontroller.js/-/esm-gamecontroller.js-1.0.4.tgz#215998947c796ead90133b4cb2cef7852729d985"
integrity sha512-787c6eP+DwFJqUsAudnb0zt7ipG+pGYjVT1vyIecsVpRW/GJw5YGV5FQT5asuMQ2k1JCnTY3MTVEUftKkyYoNw==
estree-walker@2.0.2, estree-walker@^2.0.1, estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
@@ -2945,6 +2967,11 @@ function-bind@^1.1.2:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
gamepad-events@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/gamepad-events/-/gamepad-events-0.7.0.tgz#63566540dd930f7d5997335a401118b390399494"
integrity sha512-930McsulrWFlGqzlC5plW4WQhqL+3PZ9PAaICqT7OtQLSQ5BzF+wJ32LPzjpZYaoCH7NzvEgtzadqZs8rzNkJw==
gauge@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395"
@@ -3928,11 +3955,6 @@ mlly@^1.3.0, mlly@^1.4.2, mlly@^1.6.1, mlly@^1.7.1:
pkg-types "^1.2.0"
ufo "^1.5.4"
moment@^2.30.1:
version "2.30.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==
mri@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
@@ -5580,6 +5602,14 @@ tar@^6.1.11, tar@^6.2.0:
mkdirp "^1.0.3"
yallist "^4.0.0"
tauri-plugin-gamepad-api@^0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/tauri-plugin-gamepad-api/-/tauri-plugin-gamepad-api-0.0.5.tgz#39f9137421b632956f1d617a239b08814ce2c19f"
integrity sha512-WO0xwXmPr4PMe9HzljKUbTkGv7J6ur2Df/OuEPsxBeSA5QPffAJR3N32M4pM4yW72D9Vt19XplPnHHDVATVMSw==
dependencies:
"@tauri-apps/api" "2.1.1"
tslib "^2.1.0"
terser@^5.17.4:
version "5.34.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.34.1.tgz#af40386bdbe54af0d063e0670afd55c3105abeb6"