feat: Moved to stream zip downloading.

feat: Implemented Shortcuts.
feat: Ensured it works on steam deck
This commit is contained in:
Simeon Radivoev 2026-02-21 18:28:07 +02:00
parent f15bf9a1e0
commit 62f16cbcc1
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
45 changed files with 1415 additions and 631 deletions

View file

@ -5,12 +5,14 @@ import z from "zod";
import * as schema from "../schema/app";
import fs from "node:fs/promises";
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
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 { getErrorMessage } from "@/bun/utils";
import { spawn } from "node:child_process";
export default new Elysia()
.get('/game/local/:id/cover', async ({ params: { id }, set }) =>
@ -215,29 +217,89 @@ export default new Elysia()
const localGame = await db.query.games.findFirst({
where: eq(schema.games.id, validCommand.gameId), columns: {
name: true
name: true,
source_id: true,
source: true
}
});
const game = setActiveGame({
process: Bun.spawn({
cmd: validCommand.command.command.split(' '), onExit (subprocess, exitCode, signalCode, error)
{
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
},
}),
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
await game.process.exited;
if (game.process.exitCode && game.process.exitCode > 0)
try
{
return status('Internal Server Error');
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');
} catch (error)
{
return status('Internal Server Error', getErrorMessage(error));
}
return status('OK');
}
}
}, {

View file

@ -1,11 +1,12 @@
import path, { basename, dirname } from 'node:path';
import path from 'node:path';
import { which } from 'bun';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import * as schema from '../../schema/emulators';
import { eq } from 'drizzle-orm';
import { config, emulatorsDb } from '../../app';
import os from 'node:os';
import { $ } from 'bun';
export const varRegex = /%([^%]+)%/g;
@ -78,40 +79,79 @@ export async function getValidLaunchCommands (data: {
const formattedCommands = await Promise.all(system.commands.map(async command =>
{
const label = command.label;
const cmd = command.command;
let cmd = command.command;
const matches = cmd.match(varRegex);
if (matches)
let emulator: string | undefined = undefined;
let rom = validFiles[0];
if (cmd.includes('%ESCAPESPECIALS%'))
rom = rom.replace(/[&()^=;,]/g, '');
const staticVars: Record<string, string> = {
'%ROM%': $.escape(rom),
'%ROMRAW%': validFiles[0],
'%ROMRAWWIN%': validFiles[0].replace('/', '\\'),
'%ESPATH%': path.dirname(Bun.main),
'%ROMPATH%': $.escape(gamePath),
'%BASENAME%': path.basename(validFiles[0], path.extname(validFiles[0])),
'%FILENAME%': path.basename(validFiles[0])
};
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>
{
let emulator: string | undefined = undefined;
const varList = await Promise.all(matches.map(async (value) =>
try
{
if (value.startsWith("%EMULATOR_"))
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let exec = await findExec(emulatorName);
if (data.customEmulatorConfig.has(emulatorName))
{
exec = data.customEmulatorConfig.get(emulatorName);
}
emulator = emulatorName;
return [value, exec];
return staticVars[a] ?? a;
});
if (existsSync(resolvedInjectFile))
{
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
}
const key = value.substring(1, value.length - 1);
return [value, process.env[key]];
}));
const vars = Object.fromEntries(varList);
vars['%ROM%'] = validFiles[0];
vars['%ESPATH%'] = config.get('downloadPath');
return '';
} catch (error)
{
return '';
}
});
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const matches = Array.from(cmd.matchAll(varRegex));
const varList = await Promise.all(matches.map(async ([value]) =>
{
if (value.startsWith("%EMULATOR_"))
{
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
let exec = await findExec(emulatorName);
if (data.customEmulatorConfig.has(emulatorName))
{
exec = data.customEmulatorConfig.get(emulatorName);
}
const command = cmd.replace(varRegex, (s) => vars[s] ?? '');
return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry;
}
emulator = emulatorName;
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
}
const key = value[0].substring(1, value.length - 1);
return [[value, process.env[key]]];
}));
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
vars['%ESCAPESPECIALS%'] = "";
vars['%HIDEWINDOW%'] = '';
// missing variable
const invalid = Object.entries(vars).find(c => c[1] === undefined);
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
return {
label: label ?? undefined,
command: formattedCommand,
valid: !invalid, emulator
} satisfies CommandEntry;
}));
return formattedCommands.filter(c => !!c);
@ -165,8 +205,8 @@ export async function findExec (emulatorName: string)
async function readRegistryValue (text: string)
{
const params = text.split('|');
const key = dirname(params[0]);
const value = basename(params[0]);
const key = path.dirname(params[0]);
const value = path.basename(params[0]);
const bin = params.length > 1 ? params[1] : undefined;
const proc = Bun.spawn({
@ -197,9 +237,10 @@ async function resolveStaticPath (entries: string[])
{
for (const entry of entries)
{
for await (const match of fs.glob(entry))
const resolved = entry.replace("~", os.homedir());
if (await fs.exists(resolved))
{
return match;
return resolved;
}
}
return null;

View file

@ -1,10 +1,13 @@
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
import { activeGame, customEmulators, db, events, taskQueue } from "../../app";
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
import { getValidLaunchCommands } from "./launchGameService";
import * as schema from '../../schema/app';
import { eq } from "drizzle-orm";
import { getErrorMessage } from "@/bun/utils";
import { getLocalGameMatch } from "./utils";
import { getRomApiRomsIdGet } from "@/clients/romm";
import fs from 'node:fs/promises';
import { ErrorLike } from "elysia/universal";
class CommandSearchError extends Error
{
@ -116,9 +119,19 @@ export default async function buildStatusResponse (source: string, id: number)
enqueue({ status: 'installed', details: validCommand.command.label });
}
} else
} else if (source === 'romm')
{
enqueue({ status: 'install', details: 'Install' });
// TODO: Add Caching
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
const stats = await fs.statfs(config.get('downloadPath'));
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
{
enqueue({ status: 'error', error: "Not Enough Free Space" });
} else
{
enqueue({ status: 'install', details: 'Install' });
}
}
}
}
@ -126,8 +139,15 @@ export default async function buildStatusResponse (source: string, id: number)
await sendLatests();
const dispose: Function[] = [];
const handleActiveExit = async () =>
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
{
if (data.error)
{
enqueue({
status: 'error',
error: data.error
}, 'error');
}
await sendLatests();
};
events.on('activegameexit', handleActiveExit);