feat: implemented storage management

fix: Enabled fallback secrets
feat: Made header stats actually work
feat: Made steam deck keyboard auto open for some inputs
fix: Made keybaord also work with shortcuts (no tooltips yet)
This commit is contained in:
Simeon Radivoev 2026-02-24 00:30:16 +02:00
parent 62f16cbcc1
commit e4df8fb9fb
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
55 changed files with 1675 additions and 398 deletions

View file

@ -4,15 +4,15 @@ import { and, eq, getTableColumns } from "drizzle-orm";
import z from "zod";
import * as schema from "../schema/app";
import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants";
import { getRomApiRomsIdGet, getRomsApiRomsGet, updateRomUserApiRomsIdPropsPut } from "@clients/romm";
import { InstallJob } from "../jobs/install-job";
import path from "node:path";
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
import { errorToResponse } from "elysia/adapter/bun/handler";
import { launchCommand } from "./services/launchGameService";
import { getErrorMessage } from "@/bun/utils";
import { spawn } from "node:child_process";
export default new Elysia()
.get('/game/local/:id/cover', async ({ params: { id }, set }) =>
@ -55,54 +55,64 @@ export default new Elysia()
}, {
params: z.object({ id: z.number() }),
response: z.object({ installed: z.boolean() })
}).get('/games', async ({ query: { platform_id, collection_id } }) =>
}).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) =>
{
const where: any[] = [];
if (platform_id)
if (platform_slug)
{
where.push(eq(schema.games.id, platform_id));
where.push(eq(schema.platforms.slug, platform_slug));
}
const games: FrontEndGameType[] = [];
let localGamesSet: Set<number> | undefined;
const localGames = await db.select({
platform_display_name: schema.platforms.name,
id: schema.games.id,
last_played: schema.games.last_played,
created_at: schema.games.created_at,
platform_id: schema.games.platform_id,
slug: schema.games.slug,
name: schema.games.name,
path_fs: schema.games.path_fs,
source_id: schema.games.source_id,
source: schema.games.source
}).from(schema.games).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)).where(and(...where));
const localGamesSet = new Set(localGames.map(g => g.source_id));
games.push(...localGames.map(g =>
if (!collection_id)
{
const game: FrontEndGameType = {
...g,
platform_display_name: g.platform_display_name ?? "Local",
id: { id: g.id, source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`
};
return game;
}));
const localGames = await db.select({
platform_display_name: schema.platforms.name,
id: schema.games.id,
last_played: schema.games.last_played,
created_at: schema.games.created_at,
platform_id: schema.games.platform_id,
slug: schema.games.slug,
name: schema.games.name,
path_fs: schema.games.path_fs,
source_id: schema.games.source_id,
source: schema.games.source
}).from(schema.games)
.leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id))
.where(and(...where));
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
games.push(...rommGames.data.items.filter(g => !localGamesSet.has(g.id)).map(g =>
localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!));
games.push(...localGames.map(g =>
{
const game: FrontEndGameType = {
...g,
platform_display_name: g.platform_display_name ?? "Local",
id: { id: g.id, source: 'local' },
updated_at: g.created_at,
path_cover: `/api/romm/game/local/${g.id}/cover`,
source_id: g.source_id,
source: g.source,
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`
};
return game;
}));
}
if ((!platform_source || platform_source === 'romm') || !!collection_id)
{
return convertRomToFrontend(g);
}));
const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true });
games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(g.id)).map(g =>
{
return convertRomToFrontend(g);
}));
}
return { games };
}, {
query: z.object({ platform_id: z.coerce.number().optional(), collection_id: z.coerce.number().optional() }),
query: GameListFilterSchema,
})
.get('/game/:source/:id', async ({ params: { source, id } }) =>
{
@ -188,7 +198,7 @@ export default new Elysia()
{
if (!taskQueue.hasActive())
{
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id));
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id));
return status(200);
} else
{
@ -209,97 +219,14 @@ export default new Elysia()
}
else
{
if (activeGame && activeGame.process.killed === false)
{
return status('Conflict', `${activeGame.name} currently running`);
}
const localGame = await db.query.games.findFirst({
where: eq(schema.games.id, validCommand.gameId), columns: {
name: true,
source_id: true,
source: true
}
});
try
{
await new Promise((resolve, reject) =>
{
const game = spawn(validCommand.command.command, {
shell: true
});
game.stdout.on('data', data => console.log(data));
game.on('close', (code) =>
{
events.emit('activegameexit', { exitCode: code, signalCode: null });
resolve(code);
});
game.on('error', e =>
{
events.emit('activegameexit', { exitCode: null, signalCode: null, error: e });
console.error(e);
});
setActiveGame({
pid: game.pid,
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
function updateRommProps (id: number)
{
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
events.emit('notification', { message: "Updated Last Played", type: 'success' });
}
if (source === 'romm')
{
updateRommProps(id);
}
else if (localGame?.source === 'romm' && localGame.source_id)
{
updateRommProps(localGame.source_id);
}
});
/*
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
const game = setActiveGame({
process: Bun.spawn({
cmd,
env: {
...process.env
},
onExit (subprocess, exitCode, signalCode, error)
{
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
},
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
}),
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
await game.process.exited;
if (game.process.exitCode && game.process.exitCode > 0)
{
return status('Internal Server Error');
}*/
return status('OK');
await launchCommand(validCommand.command.command, source, id, validCommand.gameId);
} catch (error)
{
console.error(error);
return status('Internal Server Error', getErrorMessage(error));
}
}
}
}, {

View file

@ -12,6 +12,14 @@ export default new Elysia()
const platforms: FrontEndPlatformType[] = [];
let rommPlatformsSet: Set<string> | undefined;
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
.from(schema.platforms)
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
.groupBy(schema.platforms.id);
const localPlatformSet = new Set(localPlatforms.filter(p => p.game_count > 0).map(p => p.slug));
if (rommPlatforms)
{
const frontEndPlatforms = rommPlatforms.map(p =>
@ -24,22 +32,17 @@ export default new Elysia()
game_count: p.rom_count,
updated_at: new Date(p.updated_at),
id: { source: 'romm', id: p.id },
source: null,
source_id: null
hasLocal: localPlatformSet.has(p.slug)
};
return platform;
});
rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug));
platforms.push(...frontEndPlatforms);
}
const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) })
.from(schema.platforms)
.leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id))
.groupBy(schema.platforms.id)
.where(notInArray(schema.platforms.slug, Array.from(rommPlatformsSet ?? [])));
platforms.push(...localPlatforms.map(p =>
platforms.push(...localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(p =>
{
const platform: FrontEndPlatformType = {
slug: p.slug,
@ -49,8 +52,7 @@ export default new Elysia()
game_count: p.game_count,
updated_at: p.created_at,
id: { source: 'local', id: p.id },
source: null,
source_id: null
hasLocal: true
};
return platform;

View file

@ -3,10 +3,13 @@ import { which } from 'bun';
import fs from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import * as schema from '../../schema/emulators';
import * as appSchema from "../../schema/app";
import { eq } from 'drizzle-orm';
import { config, emulatorsDb } from '../../app';
import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app';
import os from 'node:os';
import { $ } from 'bun';
import { spawn } from 'node:child_process';
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
export const varRegex = /%([^%]+)%/g;
@ -18,6 +21,92 @@ interface CommandEntry
emulator?: string;
}
export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number)
{
if (activeGame && activeGame.process?.killed === false)
{
throw new Error(`${activeGame.name} currently running`);
}
const localGame = await db.query.games.findFirst({
where: eq(appSchema.games.id, id), columns: {
name: true,
source_id: true,
source: true
}
});
await new Promise((resolve, reject) =>
{
const game = spawn(validCommand, {
shell: true
});
game.stdout.on('data', data => console.log(data));
game.on('close', (code) =>
{
events.emit('activegameexit', { source, id: sourceId, exitCode: code, signalCode: null });
resolve(code);
});
game.on('error', e =>
{
console.error(e);
events.emit('notification', { message: e.message, type: 'error' });
reject(e);
});
setActiveGame({
process: game,
name: localGame?.name ?? "Unknown",
gameId: id,
command: validCommand
});
function updateRommProps (id: number)
{
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
events.emit('notification', { message: "Updated Last Played", type: 'success' });
}
if (source === 'romm')
{
updateRommProps(sourceId);
}
else if (localGame?.source === 'romm' && localGame.source_id)
{
updateRommProps(localGame.source_id);
}
});
/* Old spawn lanching, cases issues, needs to be ran as shell
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
const game = setActiveGame({
process: Bun.spawn({
cmd,
env: {
...process.env
},
onExit (subprocess, exitCode, signalCode, error)
{
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
},
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
}),
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
await game.process.exited;
if (game.process.exitCode && game.process.exitCode > 0)
{
return status('Internal Server Error');
}*/
}
export async function getValidLaunchCommands (data: {
systemSlug: string;
gamePath: string;
@ -90,11 +179,11 @@ export async function getValidLaunchCommands (data: {
const staticVars: Record<string, string> = {
'%ROM%': $.escape(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': validFiles[0].replace('/', '\\'),
'%ESPATH%': path.dirname(Bun.main),
'%ROMRAWWIN%': $.escape(validFiles[0].replace('/', '\\')),
'%ESPATH%': $.escape(path.dirname(Bun.main)),
'%ROMPATH%': $.escape(gamePath),
'%BASENAME%': path.basename(validFiles[0], path.extname(validFiles[0])),
'%FILENAME%': path.basename(validFiles[0])
'%BASENAME%': $.escape(path.basename(validFiles[0], path.extname(validFiles[0]))),
'%FILENAME%': $.escape(path.basename(validFiles[0]))
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>

View file

@ -79,19 +79,39 @@ export async function getValidLaunchCommandsForGame (source: string, id: number)
export default async function buildStatusResponse (source: string, id: number)
{
let cleanup: (() => void) | undefined;
let closed = false;
return new Response(new ReadableStream({
async start (controller)
{
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh')
const encoder = new TextEncoder();
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping')
{
const evntString = event ? `event: ${event}\n` : '';
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`));
}
await sendLatests();
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
const keepAlive = setInterval(() =>
{
if (closed) return clearInterval(keepAlive);
try
{
enqueue({}, 'ping');
} catch
{
closed = true;
clearInterval(keepAlive);
}
}, 15000);
const sourceId = `${source}-${id}`;
async function sendLatests ()
{
if (closed) return;
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
if (activeTask)
@ -136,8 +156,6 @@ export default async function buildStatusResponse (source: string, id: number)
}
}
await sendLatests();
const dispose: Function[] = [];
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
{
@ -179,6 +197,7 @@ export default async function buildStatusResponse (source: string, id: number)
cleanup = () =>
{
closed = true;
dispose.forEach(f => f());
};
},