Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
Weblate
2025-08-16 02:05:27 +00:00
30 changed files with 687 additions and 232 deletions

View File

@ -14,7 +14,7 @@
[![Weblate project translated](https://img.shields.io/weblate/progress/drop?server=https%3A%2F%2Ftranslate.droposs.org&style=for-the-badge&logo=weblate) [![Weblate project translated](https://img.shields.io/weblate/progress/drop?server=https%3A%2F%2Ftranslate.droposs.org&style=for-the-badge&logo=weblate)
](https://translate.droposs.org/engage/drop/) ](https://translate.droposs.org/engage/drop/)
Drop is an open-source game distribution platform, like GameVault or Steam. It's designed to distribute and shared DRM-free game quickly, all while being incredibly flexible, beautiful and fast. Drop is an open-source game distribution platform, similar to GameVault or Steam. It's designed to distribute and share DRM-free games quickly, all while being incredibly flexible, beautiful, and fast.
<div align="center"> <div align="center">
<img src="https://droposs.org/_ipx/f_webp&q_80/images/carousel/store.png" alt="Drop Screenshot" width="900rem"/> <img src="https://droposs.org/_ipx/f_webp&q_80/images/carousel/store.png" alt="Drop Screenshot" width="900rem"/>
@ -22,9 +22,9 @@ Drop is an open-source game distribution platform, like GameVault or Steam. It's
## Philosophy ## Philosophy
1. Drop is flexible. While abstractions and interfaces can make the codebase more complicated, the flexibility is worth it. 1. Drop is flexible. While abstractions and interfaces can complicate the codebase, the flexibility is worth it.
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from a username/password to SSO. 2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from username/password to SSO.
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with complexity available to the users who want it. 3. Drop is user-friendly. The interface is designed to be clean and simple to use, with advanced features available to users who want them.
## Deployment ## Deployment

42
app.vue
View File

@ -4,10 +4,52 @@
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
<ModalStack /> <ModalStack />
<div
v-if="showExternalUrlWarning"
class="fixed flex flex-row gap-x-2 right-0 bottom-0 m-2 px-2 py-2 z-50 text-right bg-red-700/90 rounded-lg"
>
<div class="flex flex-col">
<span class="text-sm text-zinc-200 font-bold font-display">{{
$t("errors.externalUrl.title")
}}</span>
<span class="text-xs text-red-400">{{
$t("errors.externalUrl.subtitle")
}}</span>
</div>
<button class="text-red-200" @click="() => hideExternalURL()">
<XMarkIcon class="size-5" />
</button>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/outline";
await updateUser(); await updateUser();
const user = useUser();
const apiDetails = await $dropFetch("/api/v1");
const showExternalUrlWarning = ref(false);
function checkExternalUrl() {
if (!import.meta.client) return;
const realOrigin = window.location.origin.trim();
const chosenOrigin = apiDetails.external.trim();
const ignore = window.localStorage.getItem("ignoreExternalUrl");
if (ignore && ignore == "true") return;
showExternalUrlWarning.value = !(realOrigin == chosenOrigin);
}
function hideExternalURL() {
window.localStorage.setItem("ignoreExternalUrl", "true");
showExternalUrlWarning.value = false;
}
if (user.value?.admin) {
onMounted(() => {
checkExternalUrl();
});
}
</script> </script>
<style scoped> <style scoped>

View File

@ -22,21 +22,17 @@
<!-- import games button --> <!-- import games button -->
<NuxtLink <NuxtLink
:href=" :href="canImport ? `/admin/library/${game.id}/import` : ''"
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
type="button" type="button"
:class="[ :class="[
unimportedVersions.length > 0 canImport
? 'bg-blue-600 hover:bg-blue-700' ? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50', : 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600', 'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]" ]"
> >
{{ {{
unimportedVersions.length > 0 canImport
? $t("library.admin.import.version.import") ? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions") : $t("library.admin.import.version.noVersions")
}} }}
@ -124,10 +120,16 @@ import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
// TODO implement UI for this page // TODO implement UI for this page
defineProps<{ unimportedVersions: string[] }>(); const props = defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n(); const { t } = useI18n();
const hasDeleted = ref(false);
const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameAndVersions = GameModel & { versions: GameVersionModel[] }; type GameAndVersions = GameModel & { versions: GameVersionModel[] };
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref< const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions> SerializeObject<GameAndVersions>
@ -176,6 +178,7 @@ async function deleteVersion(versionName: string) {
game.value.versions.findIndex((e) => e.versionName === versionName), game.value.versions.findIndex((e) => e.versionName === versionName),
1, 1,
); );
hasDeleted.value = true;
} catch (e) { } catch (e) {
createModal( createModal(
ModalType.Notification, ModalType.Notification,

View File

@ -18,8 +18,12 @@
</i18n-t> </i18n-t>
</NuxtLink> </NuxtLink>
<DevOnly <DevOnly>
><h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1> <h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
</DevOnly> </DevOnly>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
</script>

View File

@ -0,0 +1,148 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<h3 class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.companies.modals.createTitle") }}
</h3>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.companies.modals.createDescription") }}
</p>
</div>
<div class="mt-2">
<form class="space-y-4" @submit.prevent="() => createCompany()">
<div>
<label
for="name"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t("library.admin.metadata.companies.modals.createFieldName")
}}</label
>
<div class="mt-2">
<input
id="name"
v-model="companyName"
type="text"
name="name"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldNamePlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<div>
<label
for="description"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t(
"library.admin.metadata.companies.modals.createFieldDescription",
)
}}</label
>
<div class="mt-2">
<input
id="description"
v-model="companyDescription"
type="text"
name="description"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldDescriptionPlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<div>
<label
for="website"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t("library.admin.metadata.companies.modals.createFieldWebsite")
}}</label
>
<div class="mt-2">
<input
id="website"
v-model="companyWebsite"
type="text"
name="website"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldWebsitePlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="loading"
:disabled="!companyValid"
class="w-full sm:w-fit"
@click="() => createCompany()"
>
{{ $t("common.create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { CompanyModel } from "~/prisma/client/models";
const open = defineModel<boolean>({ required: true });
const emit = defineEmits<{
created: [company: CompanyModel];
}>();
const companyName = ref("");
const companyDescription = ref("");
const companyWebsite = ref("");
const loading = ref(false);
const companyValid = computed(
() => companyName.value && companyDescription.value,
);
async function createCompany() {
loading.value = true;
try {
const newCompany = await $dropFetch("/api/v1/admin/company", {
method: "POST",
body: {
name: companyName.value,
description: companyDescription.value,
website: companyWebsite.value,
},
failTitle: "Failed to create new company",
});
open.value = false;
emit("created", newCompany);
} finally {
/* empty */
}
loading.value = false;
}
</script>

View File

@ -292,7 +292,7 @@
<div <div
v-if="games?.length ?? 0 > 0" v-if="games?.length ?? 0 > 0"
ref="product-grid" ref="product-grid"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4" class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
> >
<!-- Your content --> <!-- Your content -->
<GamePanel <GamePanel

View File

@ -212,6 +212,10 @@
"desc": "Drop encountered an error while updating the version: {error}", "desc": "Drop encountered an error while updating the version: {error}",
"title": "There an error while updating the version order" "title": "There an error while updating the version order"
} }
},
"externalUrl": {
"title": "Accessing over different EXTERNAL_URL. Please check the docs.",
"subtitle": "This message is only visible to admins."
} }
}, },
"footer": { "footer": {
@ -329,7 +333,9 @@
"noDescription": "(no description)", "noDescription": "(no description)",
"published": "Published", "published": "Published",
"uploadBanner": "Upload banner", "uploadBanner": "Upload banner",
"uploadIcon": "Upload icon" "uploadIcon": "Upload icon",
"descriptionPlaceholder": "{'<'}description{'>'}",
"websitePlaceholder": "{'<'}website{'>'}"
}, },
"modals": { "modals": {
"nameDescription": "Edit the company's name. Used to match to new game imports.", "nameDescription": "Edit the company's name. Used to match to new game imports.",
@ -337,7 +343,16 @@
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.", "shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
"shortDeckTitle": "Edit company description", "shortDeckTitle": "Edit company description",
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection.", "websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection.",
"websiteTitle": "Edit company website" "websiteTitle": "Edit company website",
"createTitle": "Create a company",
"createDescription": "Create a company to further organize your games.",
"createFieldName": "Company Name",
"createFieldNamePlaceholder": "My New Company...",
"createFieldDescription": "Company Description",
"createFieldDescriptionPlaceholder": "A small indie studio that...",
"createFieldWebsite": "Company Website",
"createFieldWebsitePlaceholder": "https://example.com/"
}, },
"noCompanies": "No companies", "noCompanies": "No companies",
"noGames": "No games", "noGames": "No games",
@ -499,7 +514,9 @@
"images": "Game Images", "images": "Game Images",
"lookAt": "Check it out", "lookAt": "Check it out",
"noDevelopers": "No developers", "noDevelopers": "No developers",
"noGame": "no game", "noGame": "NO GAME",
"noFeatured": "NO FEATURED GAMES",
"openFeatured": "Star games in Admin Library {arrow}",
"noImages": "No images", "noImages": "No images",
"noPublishers": "No publishers.", "noPublishers": "No publishers.",
"noTags": "No tags", "noTags": "No tags",

View File

@ -138,6 +138,7 @@ export default defineNuxtConfig({
scheduledTasks: { scheduledTasks: {
"0 * * * *": ["dailyTasks"], "0 * * * *": ["dailyTasks"],
"*/30 * * * *": ["downloadCleanup"],
}, },
storage: { storage: {

View File

@ -21,7 +21,7 @@
}, },
"dependencies": { "dependencies": {
"@discordapp/twemoji": "^16.0.1", "@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "1.6.0", "@drop-oss/droplet": "2.3.0",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3", "@lobomfz/prismark": "0.0.3",

View File

@ -30,7 +30,7 @@
{{ company.mName }} {{ company.mName }}
<button @click="() => editName()"> <button @click="() => editName()">
<PencilIcon <PencilIcon
class="transition duration-200 opacity-0 group-hover/name:opacity-100 size-8" class="transition duration-200 xl:opacity-0 group-hover/name:opacity-100 size-8"
/> />
</button> </button>
</h1> </h1>
@ -43,17 +43,20 @@
}} }}
<button @click="() => editShortDescription()"> <button @click="() => editShortDescription()">
<PencilIcon <PencilIcon
class="transition duration-200 opacity-0 group-hover/description:opacity-100 size-5" class="transition duration-200 xl:opacity-0 group-hover/description:opacity-100 size-5"
/> />
</button> </button>
</p> </p>
<p <p
class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3" class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3"
> >
{{ company.mWebsite }} {{
company.mWebsite ||
$t("library.admin.metadata.companies.editor.websitePlaceholder")
}}
<button @click="() => editWebsite()"> <button @click="() => editWebsite()">
<PencilIcon <PencilIcon
class="transition duration-200 opacity-0 group-hover/website:opacity-100 size-4" class="transition duration-200 xl:opacity-0 group-hover/website:opacity-100 size-4"
/> />
</button> </button>
</p> </p>

View File

@ -10,20 +10,12 @@
</p> </p>
</div> </div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink <button
to="/admin/library/sources"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createCompanyOpen = true)"
> >
<i18n-t {{ $t("common.create") }}
keypath="library.admin.sources.link" </button>
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div> </div>
</div> </div>
<div class="mt-2 grid grid-cols-1"> <div class="mt-2 grid grid-cols-1">
@ -105,6 +97,10 @@
{{ $t("library.admin.metadata.companies.noCompanies") }} {{ $t("library.admin.metadata.companies.noCompanies") }}
</p> </p>
</ul> </ul>
<ModalCreateCompany
v-model="createCompanyOpen"
@created="(company) => createCompany(company)"
/>
</div> </div>
</template> </template>
@ -122,9 +118,12 @@ useHead({
title: t("library.admin.metadata.companies.title"), title: t("library.admin.metadata.companies.title"),
}); });
const createCompanyOpen = ref(false);
const searchQuery = ref(""); const searchQuery = ref("");
const companies = ref(await $dropFetch("/api/v1/admin/company")); const rawCompanies = await $dropFetch("/api/v1/admin/company");
const companies = ref(rawCompanies);
const filteredCompanies = computed(() => const filteredCompanies = computed(() =>
companies.value.filter((e: CompanyModel) => { companies.value.filter((e: CompanyModel) => {
@ -147,4 +146,8 @@ async function deleteCompany(id: string) {
const index = companies.value.findIndex((e) => e.id === id); const index = companies.value.findIndex((e) => e.id === id);
companies.value.splice(index, 1); companies.value.splice(index, 1);
} }
function createCompany(company: (typeof companies.value)[number]) {
companies.value.push(company);
}
</script> </script>

View File

@ -47,7 +47,7 @@
/> />
</div> </div>
<p class="mt-1 truncate text-sm text-zinc-400"> <p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.value.log.at(-1) ?? "").message }} {{ parseTaskLog(task.value.log.at(-1)).message }}
</p> </p>
<NuxtLink <NuxtLink
type="button" type="button"
@ -115,7 +115,7 @@
{{ task.id }} {{ task.id }}
</p> </p>
<p class="mt-1 truncate text-sm text-zinc-400"> <p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.log.at(-1) ?? "").message }} {{ parseTaskLog(task.log.at(-1)).message }}
</p> </p>
<NuxtLink <NuxtLink
type="button" type="button"

View File

@ -59,13 +59,30 @@
</VueCarousel> </VueCarousel>
<div <div
v-else v-else
class="w-full h-full flex items-center justify-center bg-zinc-950/50 px-6 py-32 sm:px-12 sm:py-40 lg:px-16" class="w-full h-full flex flex-col items-center justify-center bg-zinc-950/50 px-6 py-32 sm:px-12 sm:py-40 lg:px-16 gap-4"
> >
<h2 <h2
class="uppercase text-xl font-bold tracking-tight text-zinc-700 sm:text-3xl" class="uppercase text-xl font-bold tracking-tight text-zinc-700 sm:text-3xl"
> >
{{ $t("store.noGame") }} {{ $t("store.noFeatured") }}
</h2> </h2>
<NuxtLink
v-if="user?.admin"
to="/admin/library"
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 duration-200 hover:scale-105 active:scale-95"
>
<i18n-t
keypath="store.openFeatured"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</div> </div>
<StoreView /> <StoreView />
@ -73,8 +90,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
const recent = await $dropFetch("/api/v1/store/featured"); const recent = await $dropFetch("/api/v1/store/featured");
const user = useUser();
const { t } = useI18n(); const { t } = useI18n();
useHead({ useHead({

View File

@ -0,0 +1,15 @@
/*
Warnings:
- The primary key for the `Task` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "Task" DROP CONSTRAINT "Task_pkey",
ADD CONSTRAINT "Task_pkey" PRIMARY KEY ("id", "started");
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -1,5 +1,5 @@
model Task { model Task {
id String @id id String
taskGroup String taskGroup String
name String name String
@ -12,4 +12,6 @@ model Task {
log String[] log String[]
acls String[] acls String[]
@@id([id, started])
} }

View File

@ -0,0 +1,47 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import * as jdenticon from "jdenticon";
import { ObjectTransactionalHandler } from "~/server/internal/objects/transactional";
import prisma from "~/server/internal/db/database";
import { MetadataSource } from "~/prisma/client/enums";
const CompanyCreate = type({
name: "string",
description: "string",
website: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:create"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, CompanyCreate);
const obj = new ObjectTransactionalHandler();
const [register, pull, _] = obj.new({}, ["internal:read"]);
const icon = jdenticon.toPng(body.name, 512);
const logoId = register(icon);
const banner = jdenticon.toPng(body.description, 1024);
const bannerId = register(banner);
const company = await prisma.company.create({
data: {
metadataSource: MetadataSource.Manual,
metadataId: crypto.randomUUID(),
metadataOriginalQuery: "",
mName: body.name,
mShortDescription: body.description,
mDescription: "",
mLogoObjectId: logoId,
mBannerObjectId: bannerId,
mWebsite: body.website,
},
});
await pull();
return company;
});

View File

@ -5,5 +5,6 @@ export default defineEventHandler((_h3) => {
appName: "Drop", appName: "Drop",
version: systemConfig.getDropVersion(), version: systemConfig.getDropVersion(),
gitRef: `#${systemConfig.getGitRef()}`, gitRef: `#${systemConfig.getGitRef()}`,
external: systemConfig.getExternalUrl(),
}; };
}); });

View File

@ -0,0 +1,86 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import contextManager from "~/server/internal/downloads/coordinator";
import libraryManager from "~/server/internal/library";
import { logger } from "~/server/internal/logging";
const GetChunk = type({
context: "string",
files: type({
filename: "string",
chunkIndex: "number",
}).array(),
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, GetChunk);
const context = await contextManager.fetchContext(body.context);
if (!context)
throw createError({
statusCode: 400,
statusMessage: "Invalid download context.",
});
const streamFiles = [];
for (const file of body.files) {
const manifestFile = context.manifest[file.filename];
if (!manifestFile)
throw createError({
statusCode: 400,
statusMessage: `Unknown file: ${file.filename}`,
});
const start = manifestFile.lengths
.slice(0, file.chunkIndex)
.reduce((a, b) => a + b, 0);
const end = start + manifestFile.lengths[file.chunkIndex];
streamFiles.push({ filename: file.filename, start, end });
}
setHeader(
h3,
"Content-Lengths",
streamFiles.map((e) => e.end - e.start).join(","),
); // Non-standard header, but we're cool like that 😎
for (const file of streamFiles) {
const gameReadStream = await libraryManager.readFile(
context.libraryId,
context.libraryPath,
context.versionName,
file.filename,
{ start: file.start, end: file.end },
);
if (!gameReadStream)
throw createError({
statusCode: 500,
statusMessage: "Failed to create read stream",
});
let length = 0;
await gameReadStream.pipeTo(
new WritableStream({
write(chunk) {
h3.node.res.write(chunk);
length += chunk.length;
},
}),
);
if (length != file.end - file.start) {
logger.warn(
`failed to read enough from ${file.filename}. read ${length}, required: ${file.end - file.start}`,
);
throw createError({
statusCode: 500,
statusMessage: "Failed to read enough from stream.",
});
}
}
await h3.node.res.end();
return;
});

View File

@ -0,0 +1,22 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import contextManager from "~/server/internal/downloads/coordinator";
const CreateContext = type({
game: "string",
version: "string",
}).configure(throwingArktype);
export default defineClientEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, CreateContext);
const context = await contextManager.createContext(body.game, body.version);
if (!context)
throw createError({
statusCode: 400,
statusMessage: "Invalid game or version",
});
return { context };
});

View File

@ -1,9 +1,68 @@
/* import prisma from "../db/database";
The download co-ordinator's job is to keep track of all the currently online clients. import type { DropManifest } from "./manifest";
When a client signs on and registers itself as a peer const TIMEOUT = 1000 * 60 * 60 * 1; // 1 hour
*/ class DownloadContextManager {
private contexts: Map<
string,
{
timeout: Date;
manifest: DropManifest;
versionName: string;
libraryId: string;
libraryPath: string;
}
> = new Map();
// eslint-disable-next-line @typescript-eslint/no-extraneous-class, @typescript-eslint/no-unused-vars async createContext(game: string, versionName: string) {
class DownloadCoordinator {} const version = await prisma.gameVersion.findUnique({
where: {
gameId_versionName: {
gameId: game,
versionName,
},
},
include: {
game: {
select: {
libraryId: true,
libraryPath: true,
},
},
},
});
if (!version) return undefined;
const contextId = crypto.randomUUID();
this.contexts.set(contextId, {
timeout: new Date(),
manifest: JSON.parse(version.dropletManifest as string) as DropManifest,
versionName,
libraryId: version.game.libraryId!,
libraryPath: version.game.libraryPath,
});
return contextId;
}
async fetchContext(contextId: string) {
const context = this.contexts.get(contextId);
if (!context) return undefined;
context.timeout = new Date();
this.contexts.set(contextId, context);
return context;
}
async cleanup() {
for (const key of this.contexts.keys()) {
const context = this.contexts.get(key)!;
if (context.timeout.getDate() + TIMEOUT < Date.now()) {
this.contexts.delete(key);
}
}
}
}
export const contextManager = new DownloadContextManager();
export default contextManager;

View File

@ -5,7 +5,7 @@ export type DropChunk = {
permissions: number; permissions: number;
ids: string[]; ids: string[];
checksums: string[]; checksums: string[];
lengths: string[]; lengths: number[];
}; };
export type DropManifest = { export type DropManifest = {

View File

@ -13,13 +13,19 @@ import { parsePlatform } from "../utils/parseplatform";
import notificationSystem from "../notifications"; import notificationSystem from "../notifications";
import { GameNotFoundError, type LibraryProvider } from "./provider"; import { GameNotFoundError, type LibraryProvider } from "./provider";
import { logger } from "../logging"; import { logger } from "../logging";
import type { GameModel } from "~/prisma/client/models";
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return btoa(`import:${libraryId}:${libraryPath}`);
}
export function createVersionImportTaskId(gameId: string, versionName: string) {
return btoa(`import:${gameId}:${versionName}`);
}
class LibraryManager { class LibraryManager {
private libraries: Map<string, LibraryProvider<unknown>> = new Map(); private libraries: Map<string, LibraryProvider<unknown>> = new Map();
private gameImportLocks: Map<string, Array<string>> = new Map(); // Library ID to Library Path
private versionImportLocks: Map<string, Array<string>> = new Map(); // Game ID to Version Name
addLibrary(library: LibraryProvider<unknown>) { addLibrary(library: LibraryProvider<unknown>) {
this.libraries.set(library.id(), library); this.libraries.set(library.id(), library);
} }
@ -37,24 +43,30 @@ class LibraryManager {
return libraryWithMetadata; return libraryWithMetadata;
} }
async fetchGamesByLibrary() {
const results: { [key: string]: { [key: string]: GameModel } } = {};
const games = await prisma.game.findMany({});
for (const game of games) {
const libraryId = game.libraryId!;
const libraryPath = game.libraryPath!;
results[libraryId] ??= {};
results[libraryId][libraryPath] = game;
}
return results;
}
async fetchUnimportedGames() { async fetchUnimportedGames() {
const unimportedGames: { [key: string]: string[] } = {}; const unimportedGames: { [key: string]: string[] } = {};
const instanceGames = await this.fetchGamesByLibrary();
for (const [id, library] of this.libraries.entries()) { for (const [id, library] of this.libraries.entries()) {
const games = await library.listGames(); const providerGames = await library.listGames();
const validGames = await prisma.game.findMany({ const providerUnimportedGames = providerGames.filter(
where: { (libraryPath) =>
libraryId: id, !instanceGames[id]?.[libraryPath] &&
libraryPath: { in: games }, !taskHandler.hasTask(createGameImportTaskId(id, libraryPath)),
},
select: {
libraryPath: true,
},
});
const providerUnimportedGames = games.filter(
(e) =>
validGames.findIndex((v) => v.libraryPath == e) == -1 &&
!(this.gameImportLocks.get(id) ?? []).includes(e),
); );
unimportedGames[id] = providerUnimportedGames; unimportedGames[id] = providerUnimportedGames;
} }
@ -84,7 +96,7 @@ class LibraryManager {
const unimportedVersions = versions.filter( const unimportedVersions = versions.filter(
(e) => (e) =>
game.versions.findIndex((v) => v.versionName == e) == -1 && game.versions.findIndex((v) => v.versionName == e) == -1 &&
!(this.versionImportLocks.get(game.id) ?? []).includes(e), !taskHandler.hasTask(createVersionImportTaskId(game.id, e)),
); );
return unimportedVersions; return unimportedVersions;
} catch (e) { } catch (e) {
@ -168,7 +180,8 @@ class LibraryManager {
for (const filename of files) { for (const filename of files) {
const basename = path.basename(filename); const basename = path.basename(filename);
const dotLocation = filename.lastIndexOf("."); const dotLocation = filename.lastIndexOf(".");
const ext = dotLocation == -1 ? "" : filename.slice(dotLocation); const ext =
dotLocation == -1 ? "" : filename.slice(dotLocation).toLowerCase();
for (const [platform, checkExts] of Object.entries(fileExts)) { for (const [platform, checkExts] of Object.entries(fileExts)) {
for (const checkExt of checkExts) { for (const checkExt of checkExts) {
if (checkExt != ext) continue; if (checkExt != ext) continue;
@ -206,70 +219,6 @@ class LibraryManager {
} }
*/ */
/**
* Locks the game so you can't be imported
* @param libraryId
* @param libraryPath
*/
async lockGame(libraryId: string, libraryPath: string) {
let games = this.gameImportLocks.get(libraryId);
if (!games) this.gameImportLocks.set(libraryId, (games = []));
if (!games.includes(libraryPath)) games.push(libraryPath);
this.gameImportLocks.set(libraryId, games);
}
/**
* Unlocks the game, call once imported
* @param libraryId
* @param libraryPath
*/
async unlockGame(libraryId: string, libraryPath: string) {
let games = this.gameImportLocks.get(libraryId);
if (!games) this.gameImportLocks.set(libraryId, (games = []));
if (games.includes(libraryPath))
games.splice(
games.findIndex((e) => e === libraryPath),
1,
);
this.gameImportLocks.set(libraryId, games);
}
/**
* Locks a version so it can't be imported
* @param gameId
* @param versionName
*/
async lockVersion(gameId: string, versionName: string) {
let versions = this.versionImportLocks.get(gameId);
if (!versions) this.versionImportLocks.set(gameId, (versions = []));
if (!versions.includes(versionName)) versions.push(versionName);
this.versionImportLocks.set(gameId, versions);
}
/**
* Unlocks the version, call once imported
* @param libraryId
* @param libraryPath
*/
async unlockVersion(gameId: string, versionName: string) {
let versions = this.versionImportLocks.get(gameId);
if (!versions) this.versionImportLocks.set(gameId, (versions = []));
if (versions.includes(gameId))
versions.splice(
versions.findIndex((e) => e === versionName),
1,
);
this.versionImportLocks.set(gameId, versions);
}
async importVersion( async importVersion(
gameId: string, gameId: string,
versionName: string, versionName: string,
@ -286,7 +235,7 @@ class LibraryManager {
umuId: string; umuId: string;
}, },
) { ) {
const taskId = `import:${gameId}:${versionName}`; const taskId = createVersionImportTaskId(gameId, versionName);
const platform = parsePlatform(metadata.platform); const platform = parsePlatform(metadata.platform);
if (!platform) return undefined; if (!platform) return undefined;
@ -300,8 +249,6 @@ class LibraryManager {
const library = this.libraries.get(game.libraryId); const library = this.libraries.get(game.libraryId);
if (!library) return undefined; if (!library) return undefined;
await this.lockVersion(gameId, versionName);
taskHandler.create({ taskHandler.create({
id: taskId, id: taskId,
taskGroup: "import:game", taskGroup: "import:game",
@ -378,9 +325,6 @@ class LibraryManager {
progress(100); progress(100);
}, },
async finally() {
await libraryManager.unlockVersion(gameId, versionName);
},
}); });
return taskId; return taskId;
@ -394,7 +338,7 @@ class LibraryManager {
) { ) {
const library = this.libraries.get(libraryId); const library = this.libraries.get(libraryId);
if (!library) return undefined; if (!library) return undefined;
return library.peekFile(game, version, filename); return await library.peekFile(game, version, filename);
} }
async readFile( async readFile(
@ -406,7 +350,7 @@ class LibraryManager {
) { ) {
const library = this.libraries.get(libraryId); const library = this.libraries.get(libraryId);
if (!library) return undefined; if (!library) return undefined;
return library.readFile(game, version, filename, options); return await library.readFile(game, version, filename, options);
} }
} }

View File

@ -7,12 +7,14 @@ import {
import { LibraryBackend } from "~/prisma/client/enums"; import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import droplet from "@drop-oss/droplet"; import droplet, { DropletHandler } from "@drop-oss/droplet";
export const FilesystemProviderConfig = type({ export const FilesystemProviderConfig = type({
baseDir: "string", baseDir: "string",
}); });
export const DROPLET_HANDLER = new DropletHandler();
export class FilesystemProvider export class FilesystemProvider
implements LibraryProvider<typeof FilesystemProviderConfig.infer> implements LibraryProvider<typeof FilesystemProviderConfig.infer>
{ {
@ -57,7 +59,7 @@ export class FilesystemProvider
const versionDirs = fs.readdirSync(gameDir); const versionDirs = fs.readdirSync(gameDir);
const validVersionDirs = versionDirs.filter((e) => { const validVersionDirs = versionDirs.filter((e) => {
const fullDir = path.join(this.config.baseDir, game, e); const fullDir = path.join(this.config.baseDir, game, e);
return droplet.hasBackendForPath(fullDir); return DROPLET_HANDLER.hasBackendForPath(fullDir);
}); });
return validVersionDirs; return validVersionDirs;
} }
@ -65,7 +67,7 @@ export class FilesystemProvider
async versionReaddir(game: string, version: string): Promise<string[]> { async versionReaddir(game: string, version: string): Promise<string[]> {
const versionDir = path.join(this.config.baseDir, game, version); const versionDir = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
return droplet.listFiles(versionDir); return DROPLET_HANDLER.listFiles(versionDir);
} }
async generateDropletManifest( async generateDropletManifest(
@ -77,10 +79,16 @@ export class FilesystemProvider
const versionDir = path.join(this.config.baseDir, game, version); const versionDir = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
const manifest = await new Promise<string>((r, j) => const manifest = await new Promise<string>((r, j) =>
droplet.generateManifest(versionDir, progress, log, (err, result) => { droplet.generateManifest(
if (err) return j(err); DROPLET_HANDLER,
r(result); versionDir,
}), progress,
log,
(err, result) => {
if (err) return j(err);
r(result);
},
),
); );
return manifest; return manifest;
} }
@ -88,7 +96,7 @@ export class FilesystemProvider
async peekFile(game: string, version: string, filename: string) { async peekFile(game: string, version: string, filename: string) {
const filepath = path.join(this.config.baseDir, game, version); const filepath = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(filepath)) return undefined; if (!fs.existsSync(filepath)) return undefined;
const stat = droplet.peekFile(filepath, filename); const stat = DROPLET_HANDLER.peekFile(filepath, filename);
return { size: Number(stat) }; return { size: Number(stat) };
} }
@ -100,13 +108,17 @@ export class FilesystemProvider
) { ) {
const filepath = path.join(this.config.baseDir, game, version); const filepath = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(filepath)) return undefined; if (!fs.existsSync(filepath)) return undefined;
const stream = droplet.readFile( let stream;
filepath, while (!(stream instanceof ReadableStream)) {
filename, const v = DROPLET_HANDLER.readFile(
options?.start ? BigInt(options.start) : undefined, filepath,
options?.end ? BigInt(options.end) : undefined, filename,
); options?.start ? BigInt(options.start) : undefined,
if (!stream) return undefined; options?.end ? BigInt(options.end) : undefined,
);
if (!v) return undefined;
stream = v.getStream() as ReadableStream<unknown>;
}
return stream; return stream;
} }

View File

@ -5,6 +5,7 @@ import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import droplet from "@drop-oss/droplet"; import droplet from "@drop-oss/droplet";
import { DROPLET_HANDLER } from "./filesystem";
export const FlatFilesystemProviderConfig = type({ export const FlatFilesystemProviderConfig = type({
baseDir: "string", baseDir: "string",
@ -46,7 +47,7 @@ export class FlatFilesystemProvider
const versionDirs = fs.readdirSync(this.config.baseDir); const versionDirs = fs.readdirSync(this.config.baseDir);
const validVersionDirs = versionDirs.filter((e) => { const validVersionDirs = versionDirs.filter((e) => {
const fullDir = path.join(this.config.baseDir, e); const fullDir = path.join(this.config.baseDir, e);
return droplet.hasBackendForPath(fullDir); return DROPLET_HANDLER.hasBackendForPath(fullDir);
}); });
return validVersionDirs; return validVersionDirs;
} }
@ -63,7 +64,7 @@ export class FlatFilesystemProvider
async versionReaddir(game: string, _version: string) { async versionReaddir(game: string, _version: string) {
const versionDir = path.join(this.config.baseDir, game); const versionDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
return droplet.listFiles(versionDir); return DROPLET_HANDLER.listFiles(versionDir);
} }
async generateDropletManifest( async generateDropletManifest(
@ -75,17 +76,23 @@ export class FlatFilesystemProvider
const versionDir = path.join(this.config.baseDir, game); const versionDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
const manifest = await new Promise<string>((r, j) => const manifest = await new Promise<string>((r, j) =>
droplet.generateManifest(versionDir, progress, log, (err, result) => { droplet.generateManifest(
if (err) return j(err); DROPLET_HANDLER,
r(result); versionDir,
}), progress,
log,
(err, result) => {
if (err) return j(err);
r(result);
},
),
); );
return manifest; return manifest;
} }
async peekFile(game: string, _version: string, filename: string) { async peekFile(game: string, _version: string, filename: string) {
const filepath = path.join(this.config.baseDir, game); const filepath = path.join(this.config.baseDir, game);
if (!fs.existsSync(filepath)) return undefined; if (!fs.existsSync(filepath)) return undefined;
const stat = droplet.peekFile(filepath, filename); const stat = DROPLET_HANDLER.peekFile(filepath, filename);
return { size: Number(stat) }; return { size: Number(stat) };
} }
async readFile( async readFile(
@ -96,7 +103,7 @@ export class FlatFilesystemProvider
) { ) {
const filepath = path.join(this.config.baseDir, game); const filepath = path.join(this.config.baseDir, game);
if (!fs.existsSync(filepath)) return undefined; if (!fs.existsSync(filepath)) return undefined;
const stream = droplet.readFile( const stream = DROPLET_HANDLER.readFile(
filepath, filepath,
filename, filename,
options?.start ? BigInt(options.start) : undefined, options?.start ? BigInt(options.start) : undefined,
@ -104,6 +111,6 @@ export class FlatFilesystemProvider
); );
if (!stream) return undefined; if (!stream) return undefined;
return stream; return stream.getStream();
} }
} }

View File

@ -18,7 +18,7 @@ import taskHandler, { wrapTaskContext } from "../tasks";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { fuzzy } from "fast-fuzzy"; import { fuzzy } from "fast-fuzzy";
import { logger } from "~/server/internal/logging"; import { logger } from "~/server/internal/logging";
import libraryManager from "../library"; import { createGameImportTaskId } from "../library";
import type { GameTagModel } from "~/prisma/client/models"; import type { GameTagModel } from "~/prisma/client/models";
export class MissingMetadataProviderConfig extends Error { export class MissingMetadataProviderConfig extends Error {
@ -185,11 +185,9 @@ export class MetadataHandler {
}); });
if (existing) return undefined; if (existing) return undefined;
await libraryManager.lockGame(libraryId, libraryPath);
const gameId = randomUUID(); const gameId = randomUUID();
const taskId = `import:${gameId}`; const taskId = createGameImportTaskId(libraryId, libraryPath);
await taskHandler.create({ await taskHandler.create({
name: `Import game "${result.name}" (${libraryPath})`, name: `Import game "${result.name}" (${libraryPath})`,
id: taskId, id: taskId,
@ -280,9 +278,6 @@ export class MetadataHandler {
logger.info(`Finished game import.`); logger.info(`Finished game import.`);
progress(100); progress(100);
}, },
async finally() {
await libraryManager.unlockGame(libraryId, libraryPath);
},
}); });
return taskId; return taskId;

View File

@ -73,6 +73,8 @@ class TaskHandler {
} }
async create(task: Task) { async create(task: Task) {
if (this.hasTask(task.id)) throw new Error("Task with ID already exists.");
let updateCollectTimeout: NodeJS.Timeout | undefined; let updateCollectTimeout: NodeJS.Timeout | undefined;
let updateCollectResolves: Array<(value: unknown) => void> = []; let updateCollectResolves: Array<(value: unknown) => void> = [];
let logOffset: number = 0; let logOffset: number = 0;
@ -206,8 +208,6 @@ class TaskHandler {
}; };
} }
if (task.finally) await task.finally();
taskEntry.endTime = new Date().toISOString(); taskEntry.endTime = new Date().toISOString();
await updateAllClients(); await updateAllClients();
@ -247,7 +247,10 @@ class TaskHandler {
) { ) {
const task = const task =
this.taskPool.get(taskId) ?? this.taskPool.get(taskId) ??
(await prisma.task.findUnique({ where: { id: taskId } })); (await prisma.task.findFirst({
where: { id: taskId },
orderBy: { started: "desc" },
}));
if (!task) { if (!task) {
peer.send( peer.send(
`error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`, `error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`,
@ -324,6 +327,10 @@ class TaskHandler {
.toArray(); .toArray();
} }
hasTask(id: string) {
return this.taskPool.has(id);
}
dailyTasks() { dailyTasks() {
return this.dailyScheduledTasks; return this.dailyScheduledTasks;
} }
@ -429,7 +436,6 @@ export interface Task {
taskGroup: TaskGroup; taskGroup: TaskGroup;
name: string; name: string;
run: (context: TaskRunContext) => Promise<void>; run: (context: TaskRunContext) => Promise<void>;
finally?: () => Promise<void> | void;
acls: GlobalACL[]; acls: GlobalACL[];
} }

View File

@ -0,0 +1,3 @@
export default defineEventHandler(async () => {
// await new Promise((r) => setTimeout(r, 700));
});

View File

@ -0,0 +1,11 @@
import contextManager from "../internal/downloads/coordinator";
export default defineTask({
meta: {
name: "downloadCleanup",
},
async run() {
await contextManager.cleanup();
return { result: true };
},
});

View File

@ -1,6 +1,9 @@
import type { TaskLog } from "~/server/internal/tasks"; import type { TaskLog } from "~/server/internal/tasks";
export function parseTaskLog(logStr: string): typeof TaskLog.infer { export function parseTaskLog(
logStr?: string | undefined,
): typeof TaskLog.infer {
if (!logStr) return { message: "", timestamp: "" };
const log = JSON.parse(logStr); const log = JSON.parse(logStr);
return { return {

114
yarn.lock
View File

@ -342,71 +342,71 @@
jsonfile "^5.0.0" jsonfile "^5.0.0"
universalify "^0.1.2" universalify "^0.1.2"
"@drop-oss/droplet-darwin-arm64@1.6.0": "@drop-oss/droplet-darwin-arm64@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-1.6.0.tgz#9697e38c46b02192e8e180b7deaaa20a389a9b0d" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-2.3.0.tgz#f4f0ded9c9f5b5cac25dd56f59817e1c13e865ab"
integrity sha512-EqTx+Mk5SHP17n19r5coacUDd7lklT4opJ2keNQyGsQjrcf+9FeCX1O5Y+PGIjpQK6UkAVdnBqM+jR7NeFmkAQ== integrity sha512-5k1VwGZTFc61FvKyL4cvYxFYB7aCY5cWCo0Q7yTkkj+KR+ewH6ucylU8kDG7M+aBLvbC/zbntXUp4RtYZi4AZQ==
"@drop-oss/droplet-darwin-universal@1.6.0": "@drop-oss/droplet-darwin-universal@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-1.6.0.tgz#2f780416052ac7d1752b0a7828dc3ef9d1789c92" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-2.3.0.tgz#1d8659bc2869e5d30308622bcc6cb230030d738e"
integrity sha512-TxVpoVDI9aGuBCHA8HktbrIkS/C1gu5laM5+ZbIZkXnIUpTicJIbHRyneXJ4MLnW703gUbW8LTISgm7xKwZJsg== integrity sha512-4V/HMnNtmHgn156pTpa3mVTAwTmO9jqtZrDcVko7PdSotEbXiwBpTFzbgb4bPafbPmkSNoRh4G9d3BLQCh4mgw==
"@drop-oss/droplet-darwin-x64@1.6.0": "@drop-oss/droplet-darwin-x64@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-1.6.0.tgz#5d6a3c596eca706e40b35cdf49ada65e59c51b8d" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-2.3.0.tgz#c7ff5dae8ba520866b7cd49714625ada8fa0a7c2"
integrity sha512-V/1xh4s16AmesDOEHiQ4vj9XQq6AWmXRY5RQf4RKBQqkxsHzmQoa37CTLK25Wf9OUoiJFGpnjViqKOFG4y5Q+g== integrity sha512-PUcNjE09N7qEFsbssKxL8rjmCt9AUYPz1yK34d8N2W9DboS1KI+PShWdd/NOk4GYzTJQuJhMp8wNcUrljfqXmQ==
"@drop-oss/droplet-linux-arm64-gnu@1.6.0": "@drop-oss/droplet-linux-arm64-gnu@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-1.6.0.tgz#265d5e7854c4c61081b8fd74b3e8305ea2c7b5ac" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-2.3.0.tgz#8819b34c5ff8bd8182c5cd0c3f1784dc2afd9507"
integrity sha512-WjaRl9VW0qE+YkOCaYuNIXzyBbps2lopbpeXELZ9/f/1jBfzfmIe4m6C2hMy4NWUcWnrBbiVTEjnq2cHj/TaBA== integrity sha512-6VyOwYu9sMrCL82UZOvvjU9G/4wHdA8P6q3+EDIVdABg5jVEYZsxkrT0Kp/5h9Xs0mPFNB/su8ZwB9FRQ63o1w==
"@drop-oss/droplet-linux-arm64-musl@1.6.0": "@drop-oss/droplet-linux-arm64-musl@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-1.6.0.tgz#7126e194e5ef9018d61ef7dd0cc3af80734e00e2" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-2.3.0.tgz#06601aa8af4bffeb26956ff79ed494265e313342"
integrity sha512-B8KoBYk0YVUZIL+etCcOc99NuoBcTm6KDOIQkN9SHWC4YLRu8um3w8DHzv4VV3arUnEGjyDHuraaOSONfP6NqA== integrity sha512-2BZreAg1XOBxr+iY2hFcX4x6bFC7AKXkIHa9130rmStH/HxnGq6K5H49eJd6ezzNMH/lQ7Sm7uJP2+sH8mjeCw==
"@drop-oss/droplet-linux-riscv64-gnu@1.6.0": "@drop-oss/droplet-linux-riscv64-gnu@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-1.6.0.tgz#40d060eafaca08b47a468950d7dc5ec4f1fb2a5a" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-2.3.0.tgz#6d5629631aeeceadb292998e21b6e2b2cf839bdc"
integrity sha512-nbNr/38EX8Mjj20+paohlOD35apmaNKZan4OO97KOwvq5oZ/pXbkjOGC0zkpsizyxbwKx7Jl4Se7teRVPWWVWw== integrity sha512-E7i86Q8IU7rh2FVtXa0NxoGRhB7AZU+AWPumTAuDQS3xPg3sj+c3q/A7YI1Ay4lnvzR/fevP2p/7iSJUEDcchQ==
"@drop-oss/droplet-linux-x64-gnu@1.6.0": "@drop-oss/droplet-linux-x64-gnu@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-1.6.0.tgz#c3a8408644194e59ac2110229e9a99885b3bc533" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-2.3.0.tgz#a924aada38dbc54f6967b17435c10bf3b6e6ffb0"
integrity sha512-n/zA1ftqGey5yQK/1HiCok3MaLA4stVTzQEuRUzyq8BQ1BC6TmKCgdFnI4Q3tuGm3/Mz2CCbfbHY4bYwND9qOQ== integrity sha512-eIHhhoSgpvMAc9tn/K0ldZRXvDe1Xyd9okSSqaclCEKjdVfWU8UMycUz1SzQH9YefiqEB4Qjd3y1iRgaEa8niA==
"@drop-oss/droplet-linux-x64-musl@1.6.0": "@drop-oss/droplet-linux-x64-musl@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-1.6.0.tgz#206b5c85b02b7fdf53bc5f0cdf68a9d9a7d501cd" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-2.3.0.tgz#4eb71112f7641e1fad3b53f5f8d1b98b9cb84bf0"
integrity sha512-egZWqKK1+vHoVKNuMle2Kn8WbbJ7Y9WJScUNXjF8hdUDNo9eHwJT/DfnA+BhvFQuJXkU58vwv6MqZ5VLdOsGiA== integrity sha512-0taR945NvK+xNBicSYriKDJgBxpcozzgcALDp/cX2UaYV9cb5PF/xw80DArCyUDvKOfRzeFALx4KRC2ghPr6tw==
"@drop-oss/droplet-win32-arm64-msvc@1.6.0": "@drop-oss/droplet-win32-arm64-msvc@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-1.6.0.tgz#fbb0387536f5b2a88f03877d730f7f863646ce08" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-2.3.0.tgz#36568f87024eb48ce7e82d76ea83a2c6ec25a856"
integrity sha512-AwGYHae8ZmQV2QGp+3B0DhsBdYynrZ4AS1xNc+U1tXt5CiMp9wLLM/4a+WySYHX7XrEo8pKmRRa0I8QdAdxk5A== integrity sha512-5HkO98h/PboM+/wPulKVGFTklijlqht8w13iW1ipUcRFsOHmS1o8nejjLL7KEr2X8G4JwYOqBeX8tY3OhaU9bw==
"@drop-oss/droplet-win32-x64-msvc@1.6.0": "@drop-oss/droplet-win32-x64-msvc@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-1.6.0.tgz#600058775641b4c5c051291e5a13135aa1ae28bb" resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-2.3.0.tgz#e794ea7cfdc0ea148707e4f3e60f2aa547328c03"
integrity sha512-Viz+J87rF7I++nLpPBvdhsjUQAHivA6wSHrBXa+4MwIymUvlQXcvNReFqzObRH4eiuiY4e3s3t9X7+paqd847Q== integrity sha512-6lNXOMyy9sPaO4wbklOIr2jbuvZHIVrd+dXu2UOI2YqFlHdxiDD1sZnqSZmlfCP58yeA+SpTfhxDHwUHJTFI/g==
"@drop-oss/droplet@1.6.0": "@drop-oss/droplet@2.3.0":
version "1.6.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-1.6.0.tgz#b6aa382dc5df494c4233a2bd8f19721878edad71" resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-2.3.0.tgz#eb2891346cf7fadcc847d5dee37674fc1106d2fc"
integrity sha512-nTZvLo+GFLlpxgFlObP4zitVctz02bRD3ZSVDiMv7jXxYK0V/GktITJFcKK0J87ZRxneoFHYbLs1lH3MFYoSIw== integrity sha512-ffEoS3LYBfPm0++p7f7F/NkYH5PfauQzuj1gTz7qVWZOSP5VQWYhOc9BEg0fsCCzTB/mct0jwOsK92URmthpxA==
optionalDependencies: optionalDependencies:
"@drop-oss/droplet-darwin-arm64" "1.6.0" "@drop-oss/droplet-darwin-arm64" "2.3.0"
"@drop-oss/droplet-darwin-universal" "1.6.0" "@drop-oss/droplet-darwin-universal" "2.3.0"
"@drop-oss/droplet-darwin-x64" "1.6.0" "@drop-oss/droplet-darwin-x64" "2.3.0"
"@drop-oss/droplet-linux-arm64-gnu" "1.6.0" "@drop-oss/droplet-linux-arm64-gnu" "2.3.0"
"@drop-oss/droplet-linux-arm64-musl" "1.6.0" "@drop-oss/droplet-linux-arm64-musl" "2.3.0"
"@drop-oss/droplet-linux-riscv64-gnu" "1.6.0" "@drop-oss/droplet-linux-riscv64-gnu" "2.3.0"
"@drop-oss/droplet-linux-x64-gnu" "1.6.0" "@drop-oss/droplet-linux-x64-gnu" "2.3.0"
"@drop-oss/droplet-linux-x64-musl" "1.6.0" "@drop-oss/droplet-linux-x64-musl" "2.3.0"
"@drop-oss/droplet-win32-arm64-msvc" "1.6.0" "@drop-oss/droplet-win32-arm64-msvc" "2.3.0"
"@drop-oss/droplet-win32-x64-msvc" "1.6.0" "@drop-oss/droplet-win32-x64-msvc" "2.3.0"
"@emnapi/core@^1.4.3": "@emnapi/core@^1.4.3":
version "1.4.5" version "1.4.5"
@ -8591,9 +8591,9 @@ tmp-promise@^3.0.2:
tmp "^0.2.0" tmp "^0.2.0"
tmp@^0.2.0: tmp@^0.2.0:
version "0.2.3" version "0.2.4"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.4.tgz#c6db987a2ccc97f812f17137b36af2b6521b0d13"
integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== integrity sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==
to-regex-range@^5.0.1: to-regex-range@^5.0.1:
version "5.0.1" version "5.0.1"