version importing

This commit is contained in:
DecDuck
2024-10-11 17:16:26 +11:00
parent a7c33e7d43
commit 46c8f0c48a
19 changed files with 587 additions and 113 deletions

10
components/LinuxLogo.vue Normal file
View File

@ -0,0 +1,10 @@
<template>
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.53918 2.40715C4.82145 1.0075 6.06066 0 7.49996 0C8.93926 0 10.1785 1.0075 10.4607 2.40715L10.798 4.07944C10.9743 4.9539 11.3217 5.78562 11.8205 6.52763L12.4009 7.39103C12.7631 7.92978 12.9999 8.5385 13.0979 9.17323C13.6747 9.22167 14.1803 9.58851 14.398 10.1283L14.8897 11.3474C15.1376 11.962 14.9583 12.665 14.4455 13.0887L12.5614 14.6458C12.0128 15.0992 11.2219 15.1193 10.6506 14.6944L9.89192 14.1301C9.88189 14.1227 9.87197 14.1151 9.86216 14.1074C9.48973 14.2075 9.09793 14.261 8.69355 14.261H6.30637C5.90201 14.261 5.51023 14.2076 5.13782 14.1074C5.12802 14.1151 5.11811 14.1227 5.10808 14.1301L4.34942 14.6944C3.77811 15.1193 2.98725 15.0992 2.43863 14.6458L0.55446 13.0887C0.0417175 12.665 -0.1376 11.962 0.110281 11.3474L0.602025 10.1283C0.819715 9.58854 1.32527 9.2217 1.90198 9.17324C2 8.5385 2.2368 7.92978 2.59897 7.39103L3.17938 6.52763C3.67818 5.78562 4.02557 4.9539 4.20193 4.07944L4.53918 2.40715ZM10.8445 9.47585C10.6345 9.63293 10.4642 9.84382 10.3561 10.0938L9.58799 11.8713C9.20026 12.0979 8.75209 12.2237 8.28465 12.2237H6.7153C6.24789 12.2237 5.79975 12.0979 5.41203 11.8714L4.64386 10.0938C4.53581 9.8438 4.36552 9.6329 4.15546 9.47582C4.18121 9.15355 4.2689 8.83503 4.41853 8.53826L5.67678 6.04259L5.68433 6.05007C6.68715 7.04458 8.31304 7.04458 9.31585 6.05007L9.32324 6.04274L10.5814 8.53825C10.7311 8.83504 10.8187 9.15357 10.8445 9.47585ZM9.04068 4.26906V3.05592H8.01353V3.85713C8.23151 3.90123 8.44506 3.97371 8.64848 4.07458L9.04068 4.26906ZM6.98638 3.85718V3.05592H5.95923V4.26919L6.3517 4.07458C6.55504 3.97375 6.7685 3.90129 6.98638 3.85718ZM2.03255 10.1864C1.82255 10.1864 1.6337 10.3132 1.55571 10.5066L1.06397 11.7257C0.981339 11.9306 1.04111 12.1649 1.21203 12.3062L3.0962 13.8633C3.27907 14.0144 3.54269 14.0211 3.73313 13.8795L4.49179 13.3152C4.6813 13.1743 4.74901 12.923 4.6557 12.7071L3.69976 10.4951C3.61884 10.3078 3.43316 10.1864 3.22771 10.1864H2.03255ZM13.4443 10.5066C13.3663 10.3132 13.1775 10.1864 12.9674 10.1864H11.7723C11.5668 10.1864 11.3812 10.3078 11.3002 10.4951L10.3443 12.7071C10.251 12.923 10.3187 13.1743 10.5082 13.3152L11.2669 13.8795C11.4573 14.0211 11.7209 14.0144 11.9038 13.8633L13.788 12.3062C13.9589 12.1649 14.0187 11.9306 13.936 11.7257L13.4443 10.5066ZM6.81106 4.98568C7.24481 4.7706 7.75537 4.7706 8.18912 4.98568L8.68739 5.23275L8.58955 5.32978C7.98786 5.92649 7.01232 5.92649 6.41063 5.32978L6.31279 5.23275L6.81106 4.98568Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,101 @@
<template>
<Listbox as="div" v-model="model">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
><slot
/></ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6"
>
<span v-if="model && values[model]" class="flex items-center">
<component
:is="values[model].icon"
alt=""
class="h-5 w-5 flex-shrink-0 text-blue-600"
/>
<span class="ml-3 block truncate">{{ values[model].name }}</span>
</span>
<span v-else>Please select a platform...</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2"
>
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-950 ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="[value, options] in Object.entries(values)"
:key="value"
:value="value"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<div class="flex items-center">
<component
:is="options.icon"
alt=""
:class="[
active ? 'text-zinc-100' : 'text-blue-600',
'h-5 w-5 flex-shrink-0',
]"
/>
<span class="ml-3 block truncate">{{ options.name }}</span>
</div>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import type { Component } from "vue";
import LinuxLogo from "./LinuxLogo.vue";
import WindowsLogo from "./WindowsLogo.vue";
const model = defineModel<string>();
const values: { [key: string]: { name: string; icon: Component } } = {
Linux: {
name: "Linux",
icon: LinuxLogo,
},
Windows: {
name: "Windows",
icon: WindowsLogo,
},
};
</script>

View File

@ -0,0 +1,12 @@
<template>
<svg
fill="currentColor"
viewBox="0 0 1920 1920"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1863.53 1016.437c31.171 0 56.47 25.299 56.47 56.47v790.589c0 16.376-7.115 31.849-19.313 42.465-10.39 9.149-23.605 14.005-37.158 14.005-2.484 0-5.082-.113-7.567-.452l-903.53-123.331c-28.008-3.84-48.903-27.784-48.903-56.02v-667.256c0-31.171 25.3-56.47 56.471-56.47Zm-1129.412 0c31.171 0 56.47 25.299 56.47 56.47v634.504c0 16.376-7.115 31.85-19.426 42.579-10.39 9.035-23.491 13.891-37.044 13.891-2.485 0-5.196-.113-7.68-.564L48.79 1669.35C20.78 1665.51 0 1641.68 0 1613.444v-540.537c0-31.171 25.299-56.47 56.47-56.47Zm-7.726-859.855c16.151-2.372 32.415 2.597 44.725 13.327 12.424 10.73 19.426 26.315 19.426 42.579V846.99c0 31.285-25.186 56.47-56.47 56.47H56.424c-31.171 0-56.47-25.185-56.47-56.47V306.455c0-28.123 20.781-52.066 48.79-55.906ZM1855.974.474c16.15-2.033 32.414 2.71 44.724 13.44 12.198 10.73 19.313 26.203 19.313 42.466v790.588c0 31.285-25.299 56.471-56.47 56.471H960.01c-31.171 0-56.47-25.186-56.47-56.47V179.711c0-28.235 20.78-52.066 48.903-55.906Z"
fill-rule="evenodd"
/>
</svg>
</template>

View File

@ -7,6 +7,7 @@ const useTaskStates = () =>
useState<{ [key: string]: Ref<TaskMessage> }>("task-states", () => ({
connect: useState<TaskMessage>("task-connect", () => ({
id: "connect",
name: "Connect",
success: false,
progress: 0,
log: [],
@ -51,8 +52,10 @@ export const useTask = (taskId: string): Ref<TaskMessage> => {
if (taskStates.value[taskId]) return taskStates.value[taskId];
if (!ws) initWs();
taskStates.value[taskId] = useState(`task-${taskId}`, () => ({
id: taskId,
name: "loading...",
success: false,
progress: 0,
error: undefined,

View File

@ -48,8 +48,8 @@
</button>
</div>
<main class="lg:pl-20 min-h-screen bg-zinc-900">
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6">
<main class="lg:pl-20 min-h-screen bg-zinc-900 flex flex-col">
<div class="flex flex-col grow px-4 py-10 sm:px-6 lg:px-8 lg:py-6">
<!-- Main area -->
<NuxtPage />
</div>

View File

@ -10,7 +10,7 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@drop/droplet": "^0.4.1",
"@drop/droplet": "^0.4.4",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@prisma/client": "5.20.0",
@ -42,7 +42,7 @@
"tailwindcss": "^3.4.13"
},
"optionalDependencies": {
"@drop/droplet-linux-x64-gnu": "^0.4.1",
"@drop/droplet-win32-x64-msvc": "^0.4.1"
"@drop/droplet-linux-x64-gnu": "^0.4.4",
"@drop/droplet-win32-x64-msvc": "^0.4.4"
}
}

View File

@ -1,74 +1,176 @@
<template>
<Listbox
as="div"
class="max-w-md"
v-on:update:model-value="(value) => updateCurrentlySelectedVersion(value)"
:model-value="currentlySelectedVersion"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select version to import</ListboxLabel
<div class="flex flex-col gap-y-4">
<Listbox
as="div"
class="max-w-md"
v-on:update:model-value="(value) => updateCurrentlySelectedVersion(value)"
:model-value="currentlySelectedVersion"
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select version to import</ListboxLabel
>
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
versions[currentlySelectedVersion]
}}</span>
<span v-else class="block truncate text-zinc-600"
>Please select a directory...</span
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versions"
:key="version"
:value="versionIdx"
v-slot="{ active, selected }"
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
versions[currentlySelectedVersion]
}}</span>
<span v-else class="block truncate text-zinc-600"
>Please select a directory...</span
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ version }}</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<span
v-if="selected"
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versions"
:key="version"
:value="versionIdx"
v-slot="{ active, selected }"
>
<li
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ version }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="flex flex-col gap-4 max-w-md" v-if="versionSettings">
<div>
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>Startup executable/command</label
>
<p class="text-zinc-400 text-xs">Executable to launch the game</p>
<div class="mt-2">
<div
class="flex rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600 sm:max-w-md"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>(install_dir)/</span
>
<input
type="text"
name="startup"
id="startup"
v-model="versionSettings.startup"
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="my-game.exe"
/>
</div>
</div>
</div>
<div>
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>Setup executable/command</label
>
<p class="text-zinc-400 text-xs">Ran once when the game is installed</p>
<div class="mt-2">
<div
class="flex rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600 sm:max-w-md"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>(install_dir)/</span
>
<input
type="text"
name="startup"
id="startup"
v-model="versionSettings.setup"
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="setup.exe"
/>
</div>
</div>
</div>
<PlatformSelector v-model="versionSettings.platform">
Version platform
</PlatformSelector>
<LoadingButton @click="startImport_wrapper" class="w-fit" :loading="importLoading">
Import
</LoadingButton>
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ importError }}
</h3>
</div>
</div>
</div>
</div>
</Listbox>
<div
v-else-if="currentlySelectedVersion != -1"
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
Loading version metadata...
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</template>
<script setup lang="ts">
@ -79,12 +181,15 @@ import {
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
definePageMeta({
layout: "admin",
});
const router = useRouter();
const route = useRoute();
const headers = useRequestHeaders(["cookie"]);
const gameId = route.params.id.toString();
@ -95,6 +200,12 @@ const versions = await $fetch(
}
);
const currentlySelectedVersion = ref(-1);
const versionSettings = ref<
{ platform: string; startup: string; setup: string } | undefined
>();
const importLoading = ref(false);
const importError = ref<string | undefined>();
async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return;
@ -105,5 +216,36 @@ async function updateCurrentlySelectedVersion(value: number) {
gameId
)}&version=${encodeURIComponent(version)}`
);
versionSettings.value = {
platform: results.platformGuess,
startup: results.startupGuess,
setup: "",
};
}
async function startImport() {
if (!versionSettings.value) return;
const taskId = await $fetch("/api/v1/admin/import/version", {
method: "POST",
body: {
id: gameId,
version: versions[currentlySelectedVersion.value],
platform: versionSettings.value.platform,
startup: versionSettings.value.startup,
setup: versionSettings.value.setup,
},
});
router.push(`/admin/task/${taskId.taskId}`);
}
function startImport_wrapper() {
importLoading.value = true;
startImport()
.catch((error) => {
importError.value = error.statusMessage ?? "An unknown error occurred.";
})
.finally(() => {
importLoading.value = false;
});
}
</script>

View File

@ -0,0 +1,11 @@
<template>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin',
})
</script>

View File

@ -178,8 +178,6 @@
</div>
</div>
</div>
{{ metadataResults }}
</div>
</template>
@ -200,7 +198,7 @@ definePageMeta({
});
const headers = useRequestHeaders(["cookie"]);
const games = await $fetch("/api/v1/admin/library/game/import", { headers });
const games = await $fetch("/api/v1/admin/import/game", { headers });
const currentlySelectedGame = ref(-1);

View File

@ -31,7 +31,7 @@
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
>
<li
v-for="{ game, status } in libraryState.games"
v-for="game in libraryNotifications"
:key="game.id"
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-700 rounded-lg bg-zinc-950/20 text-left shadow"
>
@ -58,9 +58,9 @@
</dl>
</div>
</div>
<div class="flex flex-col gap-y-2 p-2">
<div v-if="game.hasNotifications" class="flex flex-col gap-y-2 p-2">
<div
v-if="status.unimportedVersions"
v-if="game.notifications.toImport"
class="rounded-md bg-blue-600/10 p-4"
>
<div class="flex">
@ -86,7 +86,7 @@
</div>
</div>
</div>
<div v-if="status.noVersions" class="rounded-md bg-yellow-600/10 p-4">
<div v-if="game.notifications.noVersions" class="rounded-md bg-yellow-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<ExclamationTriangleIcon
@ -119,4 +119,17 @@ useHead({
const headers = useRequestHeaders(["cookie"]);
const libraryState = await $fetch("/api/v1/admin/library", { headers });
const libraryNotifications = libraryState.games.map((e) => {
const noVersions = e.status.noVersions;
const toImport = e.status.unimportedVersions.length > 0;
return {
...e.game,
notifications: {
noVersions,
toImport,
},
hasNotifications: noVersions || toImport,
}
})
</script>

View File

@ -0,0 +1,53 @@
<template>
<div
class="grow w-full flex items-center justify-center"
v-if="taskValue && taskValue.success"
>
<div class="flex flex-col items-center">
<CheckCircleIcon class="h-12 w-12 text-green-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">
Successful!
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
"{{ taskValue.name }}" completed successfully.
</p>
</div>
</div>
</div>
</div>
<div v-else-if="taskValue" class="flex flex-col w-full gap-y-4">
<h1 class="text-3xl text-zinc-100 font-bold font-display">
{{ taskValue.name }}
</h1>
<div class="h-3 rounded-full bg-zinc-950 overflow-hidden">
<div
:style="{ width: `${taskValue.progress}%` }"
class="bg-blue-600 h-full"
/>
</div>
<div class="bg-zinc-950/50 rounded-md p-2 text-zinc-100">
<pre v-for="line in taskValue.log">{{ line }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from '@heroicons/vue/16/solid';
const route = useRoute();
const taskId = route.params.id.toString();
const task = useTask(taskId);
const taskValue = computed(() => task.value);
definePageMeta({
layout: "admin",
});
useHead({
title: "Task",
});
</script>

View File

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `dropletManifest` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "GameVersion" ADD COLUMN "dropletManifest" JSONB NOT NULL;

View File

@ -104,9 +104,10 @@ model GameVersion {
game Game @relation(fields: [gameId], references: [id])
versionName String // Sub directory for the game files
platform Platform
launchCommand String // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
setupCommand String // Command to setup game (dependencies and such)
platform Platform
launchCommand String // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
setupCommand String // Command to setup game (dependencies and such)
dropletManifest Json // Results from droplet
@@id([gameId, versionName])
}

View File

@ -0,0 +1,37 @@
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => {
const user = await h3.context.session.getAdminUser(h3);
if (!user) throw createError({ statusCode: 403 });
const body = await readBody(h3);
const gameId = body.id;
const versionName = body.version;
const platform = body.platform;
const startup = body.startup;
const setup = body.setup ?? "";
if (
!gameId ||
!versionName ||
!platform ||
!startup
)
throw createError({
statusCode: 400,
statusMessage:
"Missing id, version, platform, setup or startup from body",
});
const taskId = await libraryManager.importVersion(gameId, versionName, {
platform,
startup,
setup,
});
if (!taskId)
throw createError({
statusCode: 400,
statusMessage: "Invalid options for import",
});
return { taskId: taskId };
});

View File

@ -17,8 +17,10 @@ export default defineWebSocketHandler({
peer.send("unauthenticated");
return;
}
const admin = session.getAdminUser(dummyEvent);
const peerId = uuidv4();
peer.ctx.id = peerId;
peer.ctx.admin = admin !== undefined;
const rtMsg: TaskMessage = {
id: "connect",
@ -34,7 +36,7 @@ export default defineWebSocketHandler({
const text = message.text();
if (text.startsWith("connect/")) {
const id = text.substring("connect/".length);
taskHandler.connect(peer.ctx.id, id, peer);
taskHandler.connect(peer.ctx.id, id, peer, peer.ctx.admin);
return;
}
},

View File

@ -11,6 +11,9 @@ import prisma from "../db/database";
import { GameVersion, Platform } from "@prisma/client";
import { fuzzy } from "fast-fuzzy";
import { recursivelyReaddir } from "../utils/recursivedirs";
import taskHandler from "../tasks";
import { parsePlatform } from "../utils/parseplatform";
import droplet from "@drop/droplet";
class LibraryManager {
private basePath: string;
@ -143,7 +146,7 @@ class LibraryManager {
match: number;
}> = [];
const files = recursivelyReaddir(targetDir);
const files = recursivelyReaddir(targetDir, 2);
for (const file of files) {
const filename = path.basename(file);
const dotLocation = file.lastIndexOf(".");
@ -188,6 +191,73 @@ class LibraryManager {
return true;
}
async importVersion(
gameId: string,
versionName: string,
metadata: { platform: string; setup: string; startup: string }
) {
const taskId = `import:${gameId}:${versionName}`;
const platform = parsePlatform(metadata.platform);
if (!platform) return undefined;
const game = await prisma.game.findUnique({
where: { id: gameId },
select: { mName: true, libraryBasePath: true },
});
if (!game) return undefined;
const baseDir = path.join(this.basePath, game.libraryBasePath, versionName);
if (!fs.existsSync(baseDir)) return undefined;
taskHandler.create({
id: taskId,
name: `Importing version ${versionName} for ${game.mName}`,
requireAdmin: true,
async run({ progress, log }) {
// First, create the manifest via droplet.
// This takes up 90% of our progress, so we wrap it in a *0.9
const manifest = await new Promise<string>((resolve, reject) => {
droplet.generateManifest(
baseDir,
(err, value) => {
if (err) return reject(err);
progress(value * 0.9);
},
(err, line) => {
if (err) return reject(err);
log(line);
},
(err, manifest) => {
if (err) return reject(err);
resolve(manifest);
}
);
});
log("Created manifest successfully!");
// Then, create the database object
const version = await prisma.gameVersion.create({
data: {
gameId: gameId,
versionName: versionName,
platform: platform,
setupCommand: metadata.setup,
launchCommand: metadata.startup,
dropletManifest: manifest,
},
});
log("Successfully created version!");
progress(100);
},
});
return taskId;
}
}
export const libraryManager = new LibraryManager();

View File

@ -1,3 +1,5 @@
import droplet from "@drop/droplet";
/**
* The TaskHandler setups up two-way connections to web clients and manages the state for them
* This allows long-running tasks (like game imports and such) to report progress, success and error states
@ -5,18 +7,19 @@
*/
type TaskRegistryEntry = {
runPromise: Promise<void>;
success: boolean;
progress: number;
log: string[];
error: string | undefined;
clients: { [key: string]: boolean };
name: string;
requireAdmin: boolean;
};
class TaskHandler {
private taskRegistry: { [key: string]: TaskRegistryEntry } = {};
private clientRegistry: { [key: string]: PeerImpl } = {};
startTasks: (() => void)[] = [];
constructor() {}
@ -30,17 +33,18 @@ class TaskHandler {
if (!taskEntry) return;
const taskMessage: TaskMessage = {
id: task.id,
name: task.name,
success: taskEntry.success,
progress: taskEntry.progress,
error: taskEntry.error,
log: taskEntry.log,
log: taskEntry.log.reverse().slice(0, 50),
};
for (const client of Object.keys(taskEntry.clients)) {
if (!this.clientRegistry[client]) continue;
this.clientRegistry[client].send(taskMessage);
}
updateCollectTimeout = undefined;
}, 500);
}, 100);
};
const progress = (progress: number) => {
@ -57,40 +61,46 @@ class TaskHandler {
updateAllClients();
};
const promiseRun = task.run({ progress, log });
promiseRun.then(() => {
const taskEntry = this.taskRegistry[task.id];
if (!taskEntry) return;
this.taskRegistry[task.id].success = true;
updateAllClients();
});
promiseRun.catch((error) => {
const taskEntry = this.taskRegistry[task.id];
if (!taskEntry) return;
this.taskRegistry[task.id].success = false;
this.taskRegistry[task.id].error = error;
updateAllClients();
});
this.taskRegistry[task.id] = {
name: task.name,
runPromise: promiseRun,
success: false,
progress: 0,
error: undefined,
log: [],
clients: {},
requireAdmin: task.requireAdmin ?? false,
};
droplet.callAltThreadFunc(async () => {
const promiseRun = task.run({ progress, log });
promiseRun.then(() => {
const taskEntry = this.taskRegistry[task.id];
if (!taskEntry) return;
this.taskRegistry[task.id].success = true;
updateAllClients();
});
promiseRun.catch((error) => {
const taskEntry = this.taskRegistry[task.id];
if (!taskEntry) return;
this.taskRegistry[task.id].success = false;
this.taskRegistry[task.id].error = error;
updateAllClients();
});
});
}
connect(id: string, taskId: string, peer: PeerImpl) {
connect(id: string, taskId: string, peer: PeerImpl, isAdmin = false) {
const task = this.taskRegistry[taskId];
if (!task) return false;
if (task.requireAdmin && !isAdmin) return false;
this.clientRegistry[id] = peer;
this.taskRegistry[taskId].clients[id] = true; // Uniquely insert client to avoid sending duplicate traffic
const catchupMessage: TaskMessage = {
id: taskId,
name: task.name,
success: task.success,
error: task.error,
log: task.log,
@ -127,10 +137,12 @@ export interface Task {
id: string;
name: string;
run: (context: TaskRunContext) => Promise<void>;
requireAdmin?: boolean;
}
export type TaskMessage = {
id: string;
name: string;
success: boolean;
progress: number;
error: undefined | string;

View File

@ -1,14 +1,15 @@
import fs from "fs";
import path from "path";
export function recursivelyReaddir(dir: string) {
export function recursivelyReaddir(dir: string, depth: number = 100) {
if (depth == 0) return [];
const result: Array<string> = [];
const files = fs.readdirSync(dir);
for (const file of files) {
const targetDir = path.join(dir, file);
const stat = fs.lstatSync(targetDir);
if (stat.isDirectory()) {
const subdirs = recursivelyReaddir(targetDir);
const subdirs = recursivelyReaddir(targetDir, depth - 1);
const subdirsWithBase = subdirs.map((e) => path.join(dir, e));
result.push(...subdirsWithBase);
continue;

View File

@ -296,23 +296,23 @@
dependencies:
mime "^3.0.0"
"@drop/droplet-linux-x64-gnu@0.4.1", "@drop/droplet-linux-x64-gnu@^0.4.1":
version "0.4.1"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.4.1.tgz#24f9ccebf7349bec450b855571b300284fb3731f"
integrity sha1-JPnM6/c0m+xFC4VVcbMAKE+zcx8=
"@drop/droplet-linux-x64-gnu@^0.4.4":
version "0.4.4"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.4.4.tgz#6678a0923bb13d37e20cae467f45c72bc5d9fe6e"
integrity sha1-ZnigkjuxPTfiDK5Gf0XHK8XZ/m4=
"@drop/droplet-win32-x64-msvc@0.4.1", "@drop/droplet-win32-x64-msvc@^0.4.1":
version "0.4.1"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.4.1.tgz#58238faca15b36abb02162354c2f39526bc213a1"
integrity sha1-WCOPrKFbNquwIWI1TC85UmvCE6E=
"@drop/droplet-win32-x64-msvc@^0.4.4":
version "0.4.4"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.4.4.tgz#10802bb36c6ec7d69aa17ea22081e5d5f0dac3c3"
integrity sha1-EIArs2xux9aaoX6iIIHl1fDaw8M=
"@drop/droplet@^0.4.1":
version "0.4.1"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.4.1.tgz#d4f3a7950fad2a95487ce4c014e1c782c2fcc3c7"
integrity sha1-1POnlQ+tKpVIfOTAFOHHgsL8w8c=
"@drop/droplet@^0.4.4":
version "0.4.4"
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.4.4.tgz#a9b6e3a341e85703b25c7fee597261e1b239a280"
integrity sha1-qbbjo0HoVwOyXH/uWXJh4bI5ooA=
optionalDependencies:
"@drop/droplet-linux-x64-gnu" "0.4.1"
"@drop/droplet-win32-x64-msvc" "0.4.1"
"@drop/droplet-linux-x64-gnu" "0.4.4"
"@drop/droplet-win32-x64-msvc" "0.4.4"
"@esbuild/aix-ppc64@0.20.2":
version "0.20.2"