working commit

This commit is contained in:
DecDuck
2025-10-02 17:45:54 +10:00
parent 2087531ace
commit f331661c14
7 changed files with 258 additions and 111 deletions

View File

@ -134,34 +134,41 @@
</div>
</div>
<!-- setup mode -->
<SwitchGroup as="div" class="max-w-lg flex items-center justify-between">
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
<fieldset class="max-w-lg">
<legend class="text-sm/6 font-semibold text-white">
Select an import mode
</legend>
<div class="mt-2 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
<label
v-for="mode in setupModes"
:key="mode.id"
:aria-label="mode.title"
:aria-description="mode.description"
class="cursor-pointer group relative flex rounded-lg border border-white/10 bg-zinc-800/50 p-4 has-checked:bg-blue-500/10 has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-blue-500 has-focus-visible:outline-3 has-focus-visible:-outline-offset-1 has-disabled:bg-gray-800 has-disabled:opacity-25"
>
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
$t("library.admin.import.version.setupModeDesc")
}}</SwitchDescription>
</span>
<Switch
v-model="versionSettings.onlySetup"
:class="[
versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-800',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
>
<span
aria-hidden="true"
:class="[
versionSettings.onlySetup ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
<input
type="radio"
name="mode"
:value="mode.id"
:checked="versionSettings.onlySetup === mode.value"
class="absolute inset-0 appearance-none opacity-0 focus:outline-none"
@click="versionSettings.onlySetup = mode.value"
/>
</Switch>
</SwitchGroup>
<div class="flex-1">
<span class="block text-sm font-medium text-white">{{
mode.title
}}</span>
<span class="mt-1 block text-xs text-zinc-400">{{
mode.description
}}</span>
</div>
<CheckCircleIcon
class="invisible size-5 text-blue-500 group-has-checked:visible"
aria-hidden="true"
/>
</label>
</div>
</fieldset>
<!-- launch commands -->
<div class="relative max-w-3xl">
<label
@ -462,10 +469,14 @@ import {
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { PlusIcon, TrashIcon } from "@heroicons/vue/24/outline";
import {
CheckCircleIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { ImportVersion } from "~~/server/api/v1/admin/import/version/index.post";
import type { ImportGameVersion } from "~~/server/api/v1/admin/import/version/index.post";
definePageMeta({
layout: "admin",
@ -481,9 +492,10 @@ const versions = await $dropFetch(
const userPlatforms = await useAdminPlatforms();
const allPlatforms = renderPlatforms(userPlatforms);
const currentlySelectedVersion = ref(-1);
const versionSettings = ref<Partial<typeof ImportVersion.infer>>({
id: gameId,
const versionSettings = ref<Partial<ImportGameVersion>>({
launches: [],
onlySetup: false,
});
const versionGuesses =
@ -540,7 +552,7 @@ async function updateCurrentlySelectedVersion(value: number) {
const options = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId,
)}&version=${encodeURIComponent(version)}`,
)}&version=${encodeURIComponent(version)}&mode=game`,
);
versionGuesses.value = options.map((e) => ({
...e,
@ -556,6 +568,7 @@ async function startImport() {
body: {
id: gameId,
version: versions[currentlySelectedVersion.value],
mode: "game",
...versionSettings.value,
},
});
@ -572,4 +585,26 @@ function startImport_wrapper() {
importLoading.value = false;
});
}
const setupModes: Array<{
id: string;
value: boolean;
title: string;
description: string;
}> = [
{
id: "portable",
value: false,
title: "Portable",
description:
"This mode is for games that are designed to be launched directly from the install directory. Drop works best with these.",
},
{
id: "setup",
value: true,
title: "Installer",
description:
"Also known as 'setup-only', this mode is for installers that modify the system directly, and install to directories like Program Files.",
},
];
</script>

View File

@ -0,0 +1,41 @@
/*
Warnings:
- A unique constraint covering the columns `[installRId]` on the table `LaunchOption` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[uninstallRId]` on the table `LaunchOption` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[installId]` on the table `RedistVersion` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[uninstallId]` on the table `RedistVersion` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "public"."GameTag_name_idx";
-- AlterTable
ALTER TABLE "public"."LaunchOption" ADD COLUMN "installRId" TEXT,
ADD COLUMN "uninstallRId" TEXT;
-- AlterTable
ALTER TABLE "public"."RedistVersion" ADD COLUMN "installId" TEXT,
ADD COLUMN "onlySetup" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "uninstallId" TEXT;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "public"."GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- CreateIndex
CREATE UNIQUE INDEX "LaunchOption_installRId_key" ON "public"."LaunchOption"("installRId");
-- CreateIndex
CREATE UNIQUE INDEX "LaunchOption_uninstallRId_key" ON "public"."LaunchOption"("uninstallRId");
-- CreateIndex
CREATE UNIQUE INDEX "RedistVersion_installId_key" ON "public"."RedistVersion"("installId");
-- CreateIndex
CREATE UNIQUE INDEX "RedistVersion_uninstallId_key" ON "public"."RedistVersion"("uninstallId");
-- AddForeignKey
ALTER TABLE "public"."RedistVersion" ADD CONSTRAINT "RedistVersion_installId_fkey" FOREIGN KEY ("installId") REFERENCES "public"."LaunchOption"("launchId") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."RedistVersion" ADD CONSTRAINT "RedistVersion_uninstallId_fkey" FOREIGN KEY ("uninstallId") REFERENCES "public"."LaunchOption"("launchId") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,15 @@
/*
Warnings:
- Added the required column `versionIndex` to the `RedistVersion` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "public"."GameTag_name_idx";
-- AlterTable
ALTER TABLE "public"."RedistVersion" ADD COLUMN "delta" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "versionIndex" INTEGER NOT NULL;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "public"."GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -49,6 +49,11 @@ model LaunchOption {
uninstallGId String? @unique
uninstallGVersion GameVersion? @relation(name: "uninstall")
installRId String? @unique
installRVersion RedistVersion? @relation(name: "install_redist")
uninstallRId String? @unique
uninstallRVersion RedistVersion? @relation(name: "uninstall_redist")
name String
description String
@ -125,8 +130,17 @@ model RedistVersion {
versionId String @id
version Version @relation(fields: [versionId], references: [versionId], onDelete: Cascade, onUpdate: Cascade)
installId String? @unique
install LaunchOption? @relation(name: "install_redist", fields: [installId], references: [launchId])
uninstallId String? @unique
uninstall LaunchOption? @relation(name: "uninstall_redist", fields: [uninstallId], references: [launchId])
onlySetup Boolean @default(false)
launches LaunchOption[]
versionIndex Int
delta Boolean @default(false)
gameDependees GameVersion[]
dlcDependees DLCVersion[]

View File

@ -12,14 +12,18 @@ export const LaunchCommands = type({
launchArgs: "string = ''",
}).array();
export const ImportVersion = type({
const ImportVersionBase = type({
id: "string",
version: "string",
name: "string?",
platform: "string",
onlySetup: "boolean = false",
delta: "boolean = false",
});
const ImportGameVersion = type({
mode: "'game'",
onlySetup: "boolean = false",
umuId: "string = ''",
install: "string?",
@ -27,7 +31,26 @@ export const ImportVersion = type({
launches: LaunchCommands,
uninstall: "string?",
uninstallArgs: "string?",
}).configure(throwingArktype);
});
const ImportRedistVersion = type({
mode: "'redist'",
install: "string?",
installArgs: "string?",
launches: LaunchCommands,
uninstall: "string?",
uninstallArgs: "string?",
});
export const ImportVersion = ImportVersionBase.and(
ImportGameVersion.or(ImportRedistVersion),
).configure(throwingArktype);
export type ImportGameVersion = typeof ImportVersionBase.infer &
typeof ImportGameVersion.infer;
export type ImportRedistVersion = typeof ImportVersionBase.infer &
typeof ImportRedistVersion.infer;
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]);
@ -35,48 +58,10 @@ export default defineEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, ImportVersion);
const platform = await convertIDToLink(body.platform);
if (!platform)
throw createError({ statusCode: 400, message: "Invalid platform." });
if (body.delta) {
const validOverlayVersions = await prisma.gameVersion.count({
where: {
version: {
gameId: body.id,
},
delta: false,
platform,
},
});
if (validOverlayVersions == 0)
throw createError({
statusCode: 400,
message:
"Update mode requires a pre-existing version for this platform.",
});
}
if (body.onlySetup) {
if (!body.install)
throw createError({
statusCode: 400,
message: 'Install required in "setup mode".',
});
} else {
if (!body.delta && body.launches.length == 0)
throw createError({
statusCode: 400,
message:
"At least one launch command is required for non-delta versions",
});
}
// startup & delta require more complex checking logic
const taskId = await libraryManager.importVersion(
body.id,
body.version,
"game",
body,
);
if (!taskId)

View File

@ -1,22 +1,26 @@
import { ArkErrors, type } from "arktype";
import aclManager from "~~/server/internal/acls";
import libraryManager from "~~/server/internal/library";
import libraryManager, { VersionImportModes } from "~~/server/internal/library";
export const PreloadQuery = type({
id: "string",
version: "string",
mode: type.enumerated(...VersionImportModes),
});
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const query = await getQuery(h3);
const gameId = query.id?.toString();
const versionName = query.version?.toString();
if (!gameId || !versionName)
throw createError({
statusCode: 400,
message: "Missing id or version in request params",
});
const rawQuery = await getQuery(h3);
const query = PreloadQuery(rawQuery);
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, message: query.summary });
const preload = await libraryManager.fetchUnimportedVersionInformation(
gameId,
versionName,
query.id,
query.mode,
query.version,
);
if (!preload)
throw createError({

View File

@ -18,7 +18,11 @@ import type {
GameVersionCreateInput,
LaunchOptionCreateManyInput,
VersionCreateArgs,
VersionWhereInput,
} from "~~/prisma/client/models";
import { PlatformLink } from "~~/prisma/client/client";
import { StringifiablePrefixOperator } from "arktype/internal/parser/reduce/shared.ts";
import { convertIDToLink } from "../platform/link";
export const VersionImportModes = ["game", "redist"] as const;
export type VersionImportMode = (typeof VersionImportModes)[number];
@ -216,7 +220,17 @@ class LibraryManager {
return await this.fetchLibraryObjectWithStatus(redists);
}
private async fetchLibraryPath(id: string, mode: VersionImportMode) {
private async fetchLibraryPath(
id: string,
mode: VersionImportMode,
platform?: PlatformLink,
): Promise<
| [
{ mName: string; libraryId: string; libraryPath: string } | null,
VersionWhereInput,
]
| undefined
> {
switch (mode) {
case "game":
return [
@ -224,8 +238,8 @@ class LibraryManager {
where: { id },
select: { mName: true, libraryId: true, libraryPath: true },
}),
{ gameId: id },
] as const;
{ gameId: id, gameVersions: { some: { platform } } },
];
case "redist":
return [
await prisma.redist.findUnique({
@ -233,7 +247,7 @@ class LibraryManager {
select: { mName: true, libraryId: true, libraryPath: true },
}),
{ redistId: id },
] as const;
];
}
return undefined;
}
@ -241,10 +255,9 @@ class LibraryManager {
private createVersionOptions(
id: string,
currentIndex: number,
mode: VersionImportMode,
metadata: typeof ImportVersion.infer,
): Partial<VersionCreateArgs["data"]> {
switch (mode) {
switch (metadata.mode) {
case "game":
const installCreator = {
install: {
@ -310,18 +323,20 @@ class LibraryManager {
/**
* Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported.
* @param gameId
* @param versionName
* @param id
* @param version
* @returns
*/
async fetchUnimportedVersionInformation(gameId: string, versionName: string) {
const game = await prisma.game.findUnique({
where: { id: gameId },
select: { libraryPath: true, libraryId: true, mName: true },
});
if (!game || !game.libraryId) return undefined;
async fetchUnimportedVersionInformation(
id: string,
mode: VersionImportMode,
version: string,
) {
const value = await this.fetchLibraryPath(id, mode);
if (!value?.[0] || !value[0].libraryId) return undefined;
const [libraryDetails] = value;
const library = this.libraries.get(game.libraryId);
const library = this.libraries.get(libraryDetails.libraryId);
if (!library) return undefined;
const userPlatforms = await prisma.userPlatform.findMany({});
@ -354,7 +369,10 @@ class LibraryManager {
match: number;
}> = [];
const files = await library.versionReaddir(game.libraryPath, versionName);
const files = await library.versionReaddir(
libraryDetails.libraryPath,
version,
);
for (const filename of files) {
const basename = path.basename(filename);
const dotLocation = filename.lastIndexOf(".");
@ -363,7 +381,7 @@ class LibraryManager {
for (const [platform, checkExts] of Object.entries(fileExts)) {
for (const checkExt of checkExts) {
if (checkExt != ext) continue;
const fuzzyValue = fuzzy(basename, game.mName);
const fuzzyValue = fuzzy(basename, libraryDetails.mName);
options.push({
filename,
platform,
@ -404,17 +422,56 @@ class LibraryManager {
async importVersion(
id: string,
version: string,
mode: VersionImportMode,
metadata: typeof ImportVersion.infer,
) {
const taskId = createVersionImportTaskId(id, version);
const value = await this.fetchLibraryPath(id, mode);
if (!value || !value[0]) return undefined;
if (metadata.mode === "game") {
if (metadata.onlySetup) {
if (!metadata.install)
throw createError({
statusCode: 400,
message: "An install command is required in only-setup mode.",
});
} else {
if (!metadata.delta && metadata.launches.length == 0)
throw createError({
statusCode: 400,
message:
"At least one launch command is required in non-delta, non-setup mode.",
});
}
}
const platform = await convertIDToLink(metadata.platform);
if (!platform)
throw createError({ statusCode: 400, message: "Invalid platform." });
const value = await this.fetchLibraryPath(id, metadata.mode, platform);
if (!value || !value[0])
throw createError({
statusCode: 400,
message: `${metadata.mode} not found.`,
});
const [libraryDetails, idFilter] = value;
const library = this.libraries.get(libraryDetails.libraryId);
if (!library) return undefined;
if (!library)
throw createError({
statusCode: 500,
message: "Library not found but exists in database?",
});
const currentIndex = await prisma.version.count({
where: { ...idFilter },
});
if (metadata.delta && currentIndex == 0)
throw createError({
statusCode: 400,
message:
"At least one pre-existing version of the same platform is required for delta mode.",
});
taskHandler.create({
id: taskId,
@ -439,10 +496,6 @@ class LibraryManager {
logger.info("Created manifest successfully!");
const currentIndex = await prisma.version.count({
where: { ...idFilter },
});
// Then, create the database object
await prisma.version.create({
data: {
@ -450,7 +503,7 @@ class LibraryManager {
versionName: metadata.name ?? version,
dropletManifest: manifest,
...libraryManager.createVersionOptions(id, currentIndex, mode, metadata)
...libraryManager.createVersionOptions(id, currentIndex, metadata),
},
});
@ -460,7 +513,7 @@ class LibraryManager {
nonce: `version-create-${id}-${version}`,
title: `'${libraryDetails.mName}' ('${version}') finished importing.`,
description: `Drop finished importing version ${version} for ${libraryDetails.mName}.`,
actions: [`View|/admin/library/${modeToLink[mode]}/${id}`],
actions: [`View|/admin/library/${modeToLink[metadata.mode]}/${id}`],
acls: ["system:import:version:read"],
});