From 46c8f0c48a741802ed92bcca2bc4323526d65d3d Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 11 Oct 2024 17:16:26 +1100 Subject: [PATCH] version importing --- components/LinuxLogo.vue | 10 + components/PlatformSelector.vue | 101 +++++++ components/WindowsLogo.vue | 12 + composables/task.ts | 3 + layouts/admin.vue | 4 +- package.json | 6 +- pages/admin/library/[id]/import.vue | 264 ++++++++++++++---- pages/admin/library/[id]/index.vue | 11 + pages/admin/library/import.vue | 4 +- pages/admin/library/index.vue | 21 +- pages/admin/task/[id]/index.vue | 53 ++++ .../migration.sql | 8 + prisma/schema.prisma | 7 +- .../api/v1/admin/import/version/index.post.ts | 37 +++ server/api/v1/task/index.get.ts | 4 +- server/internal/library/index.ts | 72 ++++- server/internal/tasks/index.ts | 50 ++-- server/internal/utils/recursivedirs.ts | 5 +- yarn.lock | 28 +- 19 files changed, 587 insertions(+), 113 deletions(-) create mode 100644 components/LinuxLogo.vue create mode 100644 components/PlatformSelector.vue create mode 100644 components/WindowsLogo.vue create mode 100644 pages/admin/library/[id]/index.vue create mode 100644 pages/admin/task/[id]/index.vue create mode 100644 prisma/migrations/20241011035227_add_droplet_manifest_to_game_versions/migration.sql create mode 100644 server/api/v1/admin/import/version/index.post.ts diff --git a/components/LinuxLogo.vue b/components/LinuxLogo.vue new file mode 100644 index 0000000..ceb6830 --- /dev/null +++ b/components/LinuxLogo.vue @@ -0,0 +1,10 @@ + diff --git a/components/PlatformSelector.vue b/components/PlatformSelector.vue new file mode 100644 index 0000000..cfc5600 --- /dev/null +++ b/components/PlatformSelector.vue @@ -0,0 +1,101 @@ + + + diff --git a/components/WindowsLogo.vue b/components/WindowsLogo.vue new file mode 100644 index 0000000..38a2499 --- /dev/null +++ b/components/WindowsLogo.vue @@ -0,0 +1,12 @@ + diff --git a/composables/task.ts b/composables/task.ts index 6f7fcc1..a431074 100644 --- a/composables/task.ts +++ b/composables/task.ts @@ -7,6 +7,7 @@ const useTaskStates = () => useState<{ [key: string]: Ref }>("task-states", () => ({ connect: useState("task-connect", () => ({ id: "connect", + name: "Connect", success: false, progress: 0, log: [], @@ -51,8 +52,10 @@ export const useTask = (taskId: string): Ref => { 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, diff --git a/layouts/admin.vue b/layouts/admin.vue index 26459d9..2ef98b3 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -48,8 +48,8 @@ -
-
+
+
diff --git a/package.json b/package.json index 06c2976..c07e0e1 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pages/admin/library/[id]/import.vue b/pages/admin/library/[id]/import.vue index 1589256..53bbe41 100644 --- a/pages/admin/library/[id]/import.vue +++ b/pages/admin/library/[id]/import.vue @@ -1,74 +1,176 @@ diff --git a/pages/admin/library/[id]/index.vue b/pages/admin/library/[id]/index.vue new file mode 100644 index 0000000..dbf8040 --- /dev/null +++ b/pages/admin/library/[id]/index.vue @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/pages/admin/library/import.vue b/pages/admin/library/import.vue index 172b7ea..037ef0c 100644 --- a/pages/admin/library/import.vue +++ b/pages/admin/library/import.vue @@ -178,8 +178,6 @@
- - {{ metadataResults }} @@ -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); diff --git a/pages/admin/library/index.vue b/pages/admin/library/index.vue index 1d01e3b..a1edc3c 100644 --- a/pages/admin/library/index.vue +++ b/pages/admin/library/index.vue @@ -31,7 +31,7 @@ class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" >
  • @@ -58,9 +58,9 @@ -
    +
    @@ -86,7 +86,7 @@
    -
    +
    { + const noVersions = e.status.noVersions; + const toImport = e.status.unimportedVersions.length > 0; + + return { + ...e.game, + notifications: { + noVersions, + toImport, + }, + hasNotifications: noVersions || toImport, + } +}) diff --git a/pages/admin/task/[id]/index.vue b/pages/admin/task/[id]/index.vue new file mode 100644 index 0000000..fa48e91 --- /dev/null +++ b/pages/admin/task/[id]/index.vue @@ -0,0 +1,53 @@ + + + diff --git a/prisma/migrations/20241011035227_add_droplet_manifest_to_game_versions/migration.sql b/prisma/migrations/20241011035227_add_droplet_manifest_to_game_versions/migration.sql new file mode 100644 index 0000000..97ed815 --- /dev/null +++ b/prisma/migrations/20241011035227_add_droplet_manifest_to_game_versions/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eae59f3..46bf896 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts new file mode 100644 index 0000000..162d4a5 --- /dev/null +++ b/server/api/v1/admin/import/version/index.post.ts @@ -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 }; +}); diff --git a/server/api/v1/task/index.get.ts b/server/api/v1/task/index.get.ts index 7420aba..669ffcc 100644 --- a/server/api/v1/task/index.get.ts +++ b/server/api/v1/task/index.get.ts @@ -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; } }, diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 00e5daa..70eddbb 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -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((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(); diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 1384489..ce8a249 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -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; 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; + requireAdmin?: boolean; } export type TaskMessage = { id: string; + name: string; success: boolean; progress: number; error: undefined | string; diff --git a/server/internal/utils/recursivedirs.ts b/server/internal/utils/recursivedirs.ts index a5a10a6..ee8af24 100644 --- a/server/internal/utils/recursivedirs.ts +++ b/server/internal/utils/recursivedirs.ts @@ -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 = []; 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; diff --git a/yarn.lock b/yarn.lock index af5f09e..f7a9256 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"