feat: Implemented launching and downloading of roms
This is just an initial implementation lots of kings to iron out
This commit is contained in:
parent
ef08fa6114
commit
f15bf9a1e0
117 changed files with 37776 additions and 1073 deletions
76
src/bun/api/app.ts
Normal file
76
src/bun/api/app.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
|
||||
import { TaskQueue } from "./task-queue";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { CookieJar } from 'tough-cookie';
|
||||
import FileCookieStore from 'tough-cookie-file-store';
|
||||
import path from 'node:path';
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import Conf from "conf";
|
||||
import projectPackage from '~/package.json';
|
||||
import { SERVER_URL, SettingsSchema, SettingsType } from "../../shared/constants";
|
||||
import { client } from "@clients/romm/client.gen";
|
||||
import * as schema from "./schema/app";
|
||||
import * as emulatorSchema from "./schema/emulators";
|
||||
import { login, logout } from "./auth";
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { ActiveGame } from "../types/types";
|
||||
import EventEmitter from "node:events";
|
||||
import { ErrorLike } from "bun";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
|
||||
defaults: SettingsSchema.parse({}),
|
||||
});
|
||||
export const customEmulators = new Conf<Record<string, string>>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
configName: 'custom-emulators',
|
||||
rootSchema: {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Config Path Located At: ", config.path);
|
||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||
export const jar = new CookieJar(fileCookieStore);
|
||||
await fs.mkdir(config.get('downloadPath'), { recursive: true });
|
||||
const sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
const emulatorsSqlite = new Database(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`, { readonly: true });
|
||||
export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||
export const taskQueue = new TaskQueue();
|
||||
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
||||
await login();
|
||||
export let activeGame: ActiveGame | undefined;
|
||||
export function setActiveGame (game: ActiveGame)
|
||||
{
|
||||
if (activeGame) throw new Error("Only one active game at a time");
|
||||
return activeGame = game;
|
||||
}
|
||||
export const events = new EventEmitter<AppEventMap>();
|
||||
events.addListener('activegameexit', () => activeGame = undefined);
|
||||
console.log("Logging In to Romm");
|
||||
|
||||
export async function cleanup ()
|
||||
{
|
||||
await taskQueue.close();
|
||||
sqlite.close();
|
||||
await logout();
|
||||
emulatorsSqlite.close();
|
||||
}
|
||||
|
||||
interface AppEventMap
|
||||
{
|
||||
activegameexit: [{ subprocess: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
exitapp: [];
|
||||
}
|
||||
97
src/bun/api/auth.ts
Normal file
97
src/bun/api/auth.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { config, db, jar } from "./app";
|
||||
import z from "zod";
|
||||
import { client } from "@clients/romm/client.gen";
|
||||
import { loginApiLoginPost } from "@clients/romm";
|
||||
import secrets from '../api/secrets';
|
||||
|
||||
export default new Elysia()
|
||||
.post('/login', async ({ body: { host, username, password } }) =>
|
||||
{
|
||||
if (config.has('rommAddress') && config.has('rommUser'))
|
||||
{
|
||||
await logout();
|
||||
const oldRommAddress = config.get('rommAddress');
|
||||
if (oldRommAddress)
|
||||
{
|
||||
const cookies = await jar.getCookies(oldRommAddress);
|
||||
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
|
||||
}
|
||||
}
|
||||
|
||||
config.set('rommAddress', host);
|
||||
config.set('rommUser', username);
|
||||
|
||||
await secrets.set({ service: 'gameflow', name: 'romm', value: password });
|
||||
await login();
|
||||
|
||||
return status(200);
|
||||
}, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
|
||||
.get('/login', async () =>
|
||||
{
|
||||
const credentials = await secrets.get({ service: 'gameflow', name: 'romm' });
|
||||
return { hasPassword: !!credentials };
|
||||
}, { response: z.object({ hasPassword: z.boolean() }) })
|
||||
.post('/logout', async () =>
|
||||
{
|
||||
await secrets.delete({ service: 'gameflow', name: 'romm' });
|
||||
await logout();
|
||||
const rommAddress = config.get('rommAddress');
|
||||
if (rommAddress)
|
||||
{
|
||||
const cookies = await jar.getCookies(rommAddress);
|
||||
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
|
||||
}
|
||||
return status(200);
|
||||
}, { response: z.any() });
|
||||
|
||||
async function updateClient ()
|
||||
{
|
||||
client.setConfig({
|
||||
baseUrl: config.get('rommAddress'), headers: {
|
||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout ()
|
||||
{
|
||||
if (!config.has('rommAddress'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const rommAddress = config.get('rommAddress');
|
||||
if (rommAddress)
|
||||
{
|
||||
console.log("Logging Out of ROMM");
|
||||
try
|
||||
{
|
||||
await loginApiLoginPost({
|
||||
baseUrl: rommAddress, headers: {
|
||||
'cookie': await jar.getCookieString(rommAddress)
|
||||
}
|
||||
});
|
||||
} catch (error)
|
||||
{
|
||||
console.error("Failed to logout of ROMM ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function login ()
|
||||
{
|
||||
if (!config.has('rommAddress') || !config.has('rommUser'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const rommAddress = config.get('rommAddress');
|
||||
const rommUser = config.get('rommUser');
|
||||
if (rommAddress && rommUser)
|
||||
{
|
||||
console.log("Logging In to ROMM");
|
||||
const password = await secrets.get({ service: 'gameflow', name: "romm" });
|
||||
const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
|
||||
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
|
||||
await updateClient();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +1,12 @@
|
|||
import z from "zod";
|
||||
import { config } from "./settings";
|
||||
import Elysia, { status } from "elysia";
|
||||
import keytar from '@hackolade/keytar';
|
||||
import { loginApiLoginPost } from "../../clients/romm";
|
||||
import { CookieJar } from 'tough-cookie';
|
||||
import FileCookieStore from 'tough-cookie-file-store';
|
||||
import path from 'node:path';
|
||||
import Elysia from "elysia";
|
||||
import { config, jar } from "./app";
|
||||
import games from "./games/games";
|
||||
import platforms from "./games/platforms";
|
||||
import auth from "./auth";
|
||||
|
||||
const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||
console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath);
|
||||
const jar = new CookieJar(fileCookieStore);
|
||||
await login();
|
||||
|
||||
export async function logout ()
|
||||
{
|
||||
if (!config.has('rommAddress'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const rommAddress = config.get('rommAddress');
|
||||
if (rommAddress)
|
||||
{
|
||||
console.log("Logging Out of ROMM");
|
||||
try
|
||||
{
|
||||
await loginApiLoginPost({
|
||||
baseUrl: rommAddress, headers: {
|
||||
'cookie': await jar.getCookieString(rommAddress)
|
||||
}
|
||||
});
|
||||
} catch (error)
|
||||
{
|
||||
console.error("Failed to logout of ROMM ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function login ()
|
||||
{
|
||||
if (!config.has('rommAddress') || !config.has('rommUser'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const rommAddress = config.get('rommAddress');
|
||||
const rommUser = config.get('rommUser');
|
||||
if (rommAddress && rommUser)
|
||||
{
|
||||
console.log("Logging In to ROMM");
|
||||
const password = await keytar.getPassword('romm', 'gameflow');
|
||||
const loginResponse = await loginApiLoginPost({ baseUrl: rommAddress, auth: `${rommUser}:${password}` });
|
||||
loginResponse.response.headers.getSetCookie().map(c => jar.setCookie(c, rommAddress));
|
||||
}
|
||||
}
|
||||
|
||||
export const romm = new Elysia({ prefix: "/romm" })
|
||||
.post('/login', async ({ body: { host, username, password } }) =>
|
||||
{
|
||||
if (config.has('rommAddress') && config.has('rommUser'))
|
||||
{
|
||||
await logout();
|
||||
const oldRommAddress = config.get('rommAddress');
|
||||
if (oldRommAddress)
|
||||
{
|
||||
const cookies = await jar.getCookies(oldRommAddress);
|
||||
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
|
||||
}
|
||||
}
|
||||
|
||||
config.set('rommAddress', host);
|
||||
config.set('rommUser', username);
|
||||
|
||||
await keytar.setPassword('romm', 'gameflow', password);
|
||||
await login();
|
||||
|
||||
return status(200);
|
||||
}, { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) })
|
||||
.get('/login', async () =>
|
||||
{
|
||||
const credentials = await keytar.getPassword('romm', 'gameflow');
|
||||
return { hasPassword: !!credentials };
|
||||
}, { response: z.object({ hasPassword: z.boolean() }) })
|
||||
.post('/logout', async () =>
|
||||
{
|
||||
await keytar.deletePassword('romm', 'gameflow');
|
||||
await logout();
|
||||
const rommAddress = config.get('rommAddress');
|
||||
if (rommAddress)
|
||||
{
|
||||
const cookies = await jar.getCookies(rommAddress);
|
||||
cookies.map(c => jar.store.removeCookie(c.domain, c.path, c.key));
|
||||
}
|
||||
return status(200);
|
||||
})
|
||||
export default new Elysia({ prefix: "/api/romm" })
|
||||
.use([games, platforms, auth])
|
||||
.all("/*", async ({ request, params, set }) =>
|
||||
{
|
||||
if (!config.has('rommAddress') && !config.get('rommAddress'))
|
||||
|
|
@ -119,19 +34,6 @@ export const romm = new Elysia({ prefix: "/romm" })
|
|||
redirect: 'manual', // avoid ROMM redirects
|
||||
});
|
||||
|
||||
/*
|
||||
if (rommResponse.status === 403 && config.has('rommUser'))
|
||||
{
|
||||
await login();
|
||||
headers.set('cookie', await jar.getCookieString(rommUrl.href));
|
||||
rommResponse = await fetch(url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: await request.arrayBuffer(),
|
||||
redirect: 'manual', // avoid ROMM redirects
|
||||
});
|
||||
}*/
|
||||
|
||||
set.status = rommResponse.status;
|
||||
rommResponse.headers.forEach((value, key) =>
|
||||
{
|
||||
|
|
@ -139,4 +41,5 @@ export const romm = new Elysia({ prefix: "/romm" })
|
|||
});
|
||||
|
||||
return new Response(rommResponse.body, { status: rommResponse.status });
|
||||
}).on('stop', logout);
|
||||
}, { response: z.instanceof(Response) });
|
||||
|
||||
|
|
|
|||
245
src/bun/api/games/games.ts
Normal file
245
src/bun/api/games/games.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { activeGame, config, db, events, setActiveGame, taskQueue } from "../app";
|
||||
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 { getRomApiRomsIdGet, getRomsApiRomsGet } 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";
|
||||
|
||||
export default new Elysia()
|
||||
.get('/game/local/:id/cover', async ({ params: { id }, set }) =>
|
||||
{
|
||||
const coverBlob = await db.query.games.findFirst({ columns: { cover: true, cover_type: true }, where: eq(schema.games.id, id) });
|
||||
if (!coverBlob || !coverBlob.cover)
|
||||
{
|
||||
return status(404);
|
||||
}
|
||||
if (coverBlob.cover_type)
|
||||
{
|
||||
set.headers["content-type"] = coverBlob.cover_type;
|
||||
}
|
||||
return status(200, coverBlob.cover);
|
||||
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) })
|
||||
.get('/screenshot/:id', async ({ params: { id }, set }) =>
|
||||
{
|
||||
const screenshot = await db.query.screenshots.findFirst({ where: eq(schema.screenshots.id, id), columns: { content: true, type: true } });
|
||||
if (screenshot)
|
||||
{
|
||||
if (screenshot.type)
|
||||
{
|
||||
set.headers["content-type"] = screenshot.type;
|
||||
}
|
||||
return screenshot.content;
|
||||
|
||||
}
|
||||
|
||||
return status(404);
|
||||
}, { params: z.object({ id: z.coerce.number() }) })
|
||||
.get("/game/local/:id/installed", async ({ params: { id } }) =>
|
||||
{
|
||||
const data = await db.query.games.findFirst({ where: eq(schema.games.id, id) });
|
||||
if (data && data.path_fs)
|
||||
{
|
||||
return { installed: await fs.exists(data.path_fs) };
|
||||
}
|
||||
|
||||
return { installed: false };
|
||||
}, {
|
||||
params: z.object({ id: z.number() }),
|
||||
response: z.object({ installed: z.boolean() })
|
||||
}).get('/games', async ({ query: { platform_id, collection_id } }) =>
|
||||
{
|
||||
const where: any[] = [];
|
||||
if (platform_id)
|
||||
{
|
||||
where.push(eq(schema.games.id, platform_id));
|
||||
}
|
||||
|
||||
const games: FrontEndGameType[] = [];
|
||||
|
||||
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 =>
|
||||
{
|
||||
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 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() }),
|
||||
})
|
||||
.get('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
async function getLocalGameDetailed (match: any)
|
||||
{
|
||||
const localGames = await db.select({
|
||||
platform_display_name: schema.platforms.name,
|
||||
...getTableColumns(schema.games)
|
||||
}).from(schema.games).where(match).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
|
||||
if (localGames.length > 0)
|
||||
{
|
||||
const screenshots = await db.query.screenshots.findMany({ where: eq(schema.screenshots.game_id, localGames[0].id), columns: { id: true } });
|
||||
const exists = await checkInstalled(localGames[0].path_fs);
|
||||
const fileSize = await calculateSize(localGames[0].path_fs);
|
||||
const game: FrontEndGameTypeDetailed = {
|
||||
...localGames[0],
|
||||
path_cover: `/api/romm/game/local/${localGames[0].id}/cover`,
|
||||
updated_at: localGames[0].created_at,
|
||||
id: { id: localGames[0].id, source: 'local' },
|
||||
path_platform_cover: `/api/romm/platform/local/${localGames[0].platform_id}/cover`,
|
||||
fs_size_bytes: fileSize ?? null,
|
||||
paths_screenshots: screenshots.map(s => `/api/romm/screenshot/${s.id}`),
|
||||
local: true,
|
||||
missing: !exists
|
||||
};
|
||||
return game;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (source === 'local')
|
||||
{
|
||||
|
||||
const localGame = await getLocalGameDetailed(eq(schema.games.id, id));
|
||||
if (localGame) return localGame;
|
||||
return status('Not Found');
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||
if (localGame) return localGame;
|
||||
|
||||
const rom = await getRomApiRomsIdGet({ path: { id } });
|
||||
if (rom.data)
|
||||
{
|
||||
const romGame = convertRomToFrontendDetailed(rom.data);
|
||||
return romGame;
|
||||
}
|
||||
|
||||
return status("Not Found", rom.response);
|
||||
}
|
||||
|
||||
}, {
|
||||
params: z.object({ source: z.string(), id: z.coerce.number() })
|
||||
})
|
||||
.get('/status/:source/:id', async ({ params: { source, id }, set }) =>
|
||||
{
|
||||
set.headers["content-type"] = 'text/event-stream';
|
||||
set.headers["cache-control"] = 'no-cache';
|
||||
set.headers['connection'] = 'keep-alive';
|
||||
return buildStatusResponse(source, id);
|
||||
}, {
|
||||
response: z.any(),
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
query: z.object({ isLocal: z.boolean().optional() })
|
||||
})
|
||||
.delete('/game/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
const deleted = await db.delete(schema.games).where(getLocalGameMatch(id, source)).returning({ path_fs: schema.games.path_fs });
|
||||
const downloadPath = config.get('downloadPath');
|
||||
await Promise.all(deleted.filter(d => !!d.path_fs).map(async d =>
|
||||
{
|
||||
await fs.rm(path.join(downloadPath, d.path_fs!), { recursive: true, force: true });
|
||||
}));
|
||||
|
||||
return status(deleted.length > 0 ? 'OK' : 'Not Modified');
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
})
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
{
|
||||
if (!taskQueue.hasActive())
|
||||
{
|
||||
taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id));
|
||||
return status(200);
|
||||
} else
|
||||
{
|
||||
return status('Not Implemented');
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
response: z.any()
|
||||
})
|
||||
.post('/game/:source/:id/play', async ({ params: { id, source }, set }) =>
|
||||
{
|
||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommand)
|
||||
{
|
||||
if (validCommand instanceof Error)
|
||||
{
|
||||
return errorToResponse(validCommand, set);
|
||||
}
|
||||
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
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
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)
|
||||
{
|
||||
return status('Internal Server Error');
|
||||
}
|
||||
return status('OK');
|
||||
}
|
||||
}
|
||||
}, {
|
||||
params: z.object({ id: z.coerce.number(), source: z.string() }),
|
||||
});
|
||||
86
src/bun/api/games/platforms.ts
Normal file
86
src/bun/api/games/platforms.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { getPlatformApiPlatformsIdGet, getPlatformsApiPlatformsGet } from "@clients/romm";
|
||||
import z from "zod";
|
||||
import { count, eq, getTableColumns, notInArray } from "drizzle-orm";
|
||||
import { db } from "../app";
|
||||
import { FrontEndPlatformType } from "@shared/constants";
|
||||
import * as schema from "../schema/app";
|
||||
|
||||
export default new Elysia()
|
||||
.get('/platforms', async () =>
|
||||
{
|
||||
const platforms: FrontEndPlatformType[] = [];
|
||||
let rommPlatformsSet: Set<string> | undefined;
|
||||
const { data: rommPlatforms } = await getPlatformsApiPlatformsGet();
|
||||
if (rommPlatforms)
|
||||
{
|
||||
const frontEndPlatforms = rommPlatforms.map(p =>
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: p.slug,
|
||||
name: p.display_name,
|
||||
family_name: p.family_name,
|
||||
path_cover: `/api/romm/assets/platforms/${p.slug}.svg`,
|
||||
game_count: p.rom_count,
|
||||
updated_at: new Date(p.updated_at),
|
||||
id: { source: 'romm', id: p.id },
|
||||
source: null,
|
||||
source_id: null
|
||||
};
|
||||
|
||||
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 =>
|
||||
{
|
||||
const platform: FrontEndPlatformType = {
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
family_name: p.family_name,
|
||||
path_cover: `/api/romm/platform/local/${p.id}/cover`,
|
||||
game_count: p.game_count,
|
||||
updated_at: p.created_at,
|
||||
id: { source: 'local', id: p.id },
|
||||
source: null,
|
||||
source_id: null
|
||||
};
|
||||
|
||||
return platform;
|
||||
}));
|
||||
|
||||
return { platforms };
|
||||
}).get('/platforms/:source/:id', async ({ params: { source, id } }) =>
|
||||
{
|
||||
const rommPlatform = await getPlatformApiPlatformsIdGet({ path: { id } });
|
||||
if (rommPlatform.data)
|
||||
{
|
||||
return rommPlatform.data;
|
||||
}
|
||||
|
||||
return status("Not Found", rommPlatform.response);
|
||||
}, { params: z.object({ source: z.string(), id: z.coerce.number() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) =>
|
||||
{
|
||||
const coverBlob = await db.query.platforms.findFirst({
|
||||
columns: {
|
||||
cover: true, cover_type: true
|
||||
|
||||
}, where: eq(schema.platforms.id, id)
|
||||
});
|
||||
if (!coverBlob || !coverBlob.cover)
|
||||
{
|
||||
return status(404);
|
||||
}
|
||||
if (coverBlob.cover_type)
|
||||
{
|
||||
set.headers["content-type"] = coverBlob.cover_type;
|
||||
}
|
||||
return status(200, coverBlob.cover);
|
||||
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) });
|
||||
219
src/bun/api/games/services/launchGameService.ts
Normal file
219
src/bun/api/games/services/launchGameService.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import path, { basename, dirname } from 'node:path';
|
||||
import { which } from 'bun';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as schema from '../../schema/emulators';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config, emulatorsDb } from '../../app';
|
||||
import os from 'node:os';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
|
||||
interface CommandEntry
|
||||
{
|
||||
label?: string;
|
||||
command: string;
|
||||
valid: boolean;
|
||||
emulator?: string;
|
||||
}
|
||||
|
||||
export async function getValidLaunchCommands (data: {
|
||||
systemSlug: string;
|
||||
gamePath: string;
|
||||
customEmulatorConfig: {
|
||||
get: (id: string) => string | undefined,
|
||||
has: (id: string) => boolean,
|
||||
};
|
||||
}): Promise<CommandEntry[]>
|
||||
{
|
||||
|
||||
const system = await emulatorsDb.query.systems.findFirst({ with: { commands: true }, where: eq(schema.systems.name, data.systemSlug) });
|
||||
|
||||
if (!system)
|
||||
{
|
||||
throw new Error(`Could not find system '${data.systemSlug}'`);
|
||||
}
|
||||
|
||||
if (!system.extension || system.extension.length <= 0)
|
||||
{
|
||||
throw new Error(`No extensions listed for system '${data.systemSlug}'`);
|
||||
}
|
||||
|
||||
const downloadPath = config.get('downloadPath');
|
||||
const gamePath = path.join(downloadPath, data.gamePath);
|
||||
|
||||
const validFiles: string[] = [];
|
||||
if (!existsSync(gamePath))
|
||||
{
|
||||
throw new Error(`Provided rom path is missing: '${gamePath}'`);
|
||||
}
|
||||
|
||||
const gamePathStat = await fs.stat(gamePath);
|
||||
|
||||
const extensionList = system.extension.join(',');
|
||||
|
||||
if (gamePathStat.isDirectory())
|
||||
{
|
||||
for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`)))
|
||||
{
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
if (validFiles.length <= 0)
|
||||
{
|
||||
throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`);
|
||||
}
|
||||
} else
|
||||
{
|
||||
if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase())))
|
||||
{
|
||||
validFiles.push(gamePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedCommands = await Promise.all(system.commands.map(async command =>
|
||||
{
|
||||
const label = command.label;
|
||||
const cmd = command.command;
|
||||
|
||||
const matches = cmd.match(varRegex);
|
||||
if (matches)
|
||||
{
|
||||
let emulator: string | undefined = undefined;
|
||||
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);
|
||||
}
|
||||
|
||||
emulator = emulatorName;
|
||||
return [value, exec];
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
// missing variable
|
||||
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
||||
|
||||
const command = cmd.replace(varRegex, (s) => vars[s] ?? '');
|
||||
return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry;
|
||||
}
|
||||
}));
|
||||
|
||||
return formattedCommands.filter(c => !!c);
|
||||
}
|
||||
|
||||
export async function findExec (emulatorName: string)
|
||||
{
|
||||
const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) });
|
||||
if (!emulator)
|
||||
{
|
||||
throw new Error(`Could not find emulator ${emulatorName}`);
|
||||
}
|
||||
if (os.platform() === 'win32')
|
||||
{
|
||||
const regValues = emulator.winregistrypath;
|
||||
if (regValues.length > 0)
|
||||
{
|
||||
for (const node of regValues)
|
||||
{
|
||||
const registryValue = await readRegistryValue(node);
|
||||
if (registryValue)
|
||||
{
|
||||
return registryValue;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const systempaths = emulator.systempath;
|
||||
if (systempaths.length > 0)
|
||||
{
|
||||
const systemPath = await resolveSystemPath(systempaths);
|
||||
if (systemPath)
|
||||
{
|
||||
return systemPath;
|
||||
}
|
||||
}
|
||||
|
||||
const staticPaths = emulator.staticpath;
|
||||
if (staticPaths.length > 0)
|
||||
{
|
||||
const staticPath = await resolveStaticPath(staticPaths);
|
||||
if (staticPath)
|
||||
{
|
||||
return staticPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readRegistryValue (text: string)
|
||||
{
|
||||
const params = text.split('|');
|
||||
const key = dirname(params[0]);
|
||||
const value = basename(params[0]);
|
||||
const bin = params.length > 1 ? params[1] : undefined;
|
||||
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["reg", "QUERY", key, "/v", value],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (!output.includes(value)) return null;
|
||||
|
||||
const lines = output.split("\n");
|
||||
for (const line of lines)
|
||||
{
|
||||
if (line.includes(value))
|
||||
{
|
||||
const parts = line.trim().split(/\s{4,}/);
|
||||
return bin ? path.join(parts[2], bin) : parts[2]; // registry value
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveStaticPath (entries: string[])
|
||||
{
|
||||
for (const entry of entries)
|
||||
{
|
||||
for await (const match of fs.glob(entry))
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveSystemPath (entries: string[])
|
||||
{
|
||||
for (const entry of entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
const found = which(entry);
|
||||
return found;
|
||||
} catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
171
src/bun/api/games/services/statusService.ts
Normal file
171
src/bun/api/games/services/statusService.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
|
||||
import { activeGame, 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";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
constructor(status: GameStatusType, message: string)
|
||||
{
|
||||
super(message);
|
||||
this.name = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLocalGame (source: string, id: number)
|
||||
{
|
||||
const localGames = await db.select({ id: schema.games.id, path_fs: schema.games.path_fs, platform_slug: schema.platforms.es_slug })
|
||||
.from(schema.games)
|
||||
.where(getLocalGameMatch(id, source))
|
||||
.leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id));
|
||||
|
||||
if (localGames.length > 0)
|
||||
{
|
||||
return localGames[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getValidLaunchCommandsForGame (source: string, id: number)
|
||||
{
|
||||
const localGame = await getLocalGame(source, id);
|
||||
if (localGame)
|
||||
{
|
||||
if (localGame.platform_slug)
|
||||
{
|
||||
if (localGame.path_fs)
|
||||
{
|
||||
try
|
||||
{
|
||||
const commands = await getValidLaunchCommands({ systemSlug: localGame.platform_slug, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
|
||||
const validCommand = commands.find(c => c.valid);
|
||||
if (validCommand)
|
||||
{
|
||||
return { command: validCommand, gameId: localGame.id, source: source, sourceId: id };
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator).join(',')}`);
|
||||
}
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
return new CommandSearchError('error', getErrorMessage(error));
|
||||
}
|
||||
|
||||
} else
|
||||
{
|
||||
return new CommandSearchError('error', 'Missing Path');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return new CommandSearchError('error', 'Missing Platform');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default async function buildStatusResponse (source: string, id: number)
|
||||
{
|
||||
let cleanup: (() => void) | undefined;
|
||||
return new Response(new ReadableStream({
|
||||
async start (controller)
|
||||
{
|
||||
function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh')
|
||||
{
|
||||
const evntString = event ? `event: ${event}\n` : '';
|
||||
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
const sourceId = `${source}-${id}`;
|
||||
|
||||
async function sendLatests ()
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } });
|
||||
const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`);
|
||||
if (activeTask)
|
||||
{
|
||||
enqueue({
|
||||
progress: activeTask.progress,
|
||||
status: activeTask.state as any
|
||||
});
|
||||
|
||||
} else if (activeGame && activeGame.gameId === localGame?.id)
|
||||
{
|
||||
enqueue({ status: 'playing' as GameStatusType, details: 'Playing' });
|
||||
}
|
||||
else
|
||||
{
|
||||
const validCommand = await getValidLaunchCommandsForGame(source, id);
|
||||
if (validCommand)
|
||||
{
|
||||
if (validCommand instanceof Error)
|
||||
{
|
||||
enqueue({ status: validCommand.name as GameStatusType, error: validCommand.message });
|
||||
}
|
||||
else
|
||||
{
|
||||
enqueue({ status: 'installed', details: validCommand.command.label });
|
||||
}
|
||||
|
||||
} else
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await sendLatests();
|
||||
|
||||
const dispose: Function[] = [];
|
||||
const handleActiveExit = async () =>
|
||||
{
|
||||
await sendLatests();
|
||||
};
|
||||
events.on('activegameexit', handleActiveExit);
|
||||
dispose.push(() => events.off('activegameexit', handleActiveExit));
|
||||
dispose.push(taskQueue.on('progress', ({ id, progress, state }) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
{
|
||||
enqueue({ progress, status: state as any });
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('completed', ({ id }) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
{
|
||||
enqueue({}, 'refresh');
|
||||
}
|
||||
}));
|
||||
dispose.push(taskQueue.on('error', ({ id, error }) =>
|
||||
{
|
||||
if (id.endsWith(sourceId))
|
||||
{
|
||||
enqueue({
|
||||
status: 'error',
|
||||
error: error
|
||||
}, 'error');
|
||||
}
|
||||
}));
|
||||
|
||||
cleanup = () =>
|
||||
{
|
||||
dispose.forEach(f => f());
|
||||
};
|
||||
},
|
||||
cancel (reason)
|
||||
{
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
},
|
||||
}));
|
||||
}
|
||||
65
src/bun/api/games/services/utils.ts
Normal file
65
src/bun/api/games/services/utils.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import getFolderSize from "get-folder-size";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config } from "../../app";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as schema from "../../schema/app";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
|
||||
import { DetailedRomSchema, SimpleRomSchema } from "@clients/romm";
|
||||
|
||||
export async function calculateSize (installPath: string | null)
|
||||
{
|
||||
if (!installPath) return null;
|
||||
return (await getFolderSize(path.join(config.get('downloadPath'), installPath))).size;
|
||||
}
|
||||
|
||||
export async function checkInstalled (installPath: string | null)
|
||||
{
|
||||
if (!installPath) return false;
|
||||
return fs.exists(path.join(config.get('downloadPath'), installPath));
|
||||
}
|
||||
|
||||
export function getLocalGameMatch (id: number, source: string)
|
||||
{
|
||||
return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, id);
|
||||
}
|
||||
|
||||
export function convertRomToFrontend (rom: SimpleRomSchema): FrontEndGameType
|
||||
{
|
||||
const game: FrontEndGameType = {
|
||||
id: { id: rom.id, source: 'romm' },
|
||||
path_cover: `/api/romm${rom.path_cover_large}`,
|
||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||
updated_at: new Date(rom.updated_at),
|
||||
slug: rom.slug,
|
||||
platform_id: rom.platform_id,
|
||||
platform_display_name: rom.platform_display_name,
|
||||
name: rom.name,
|
||||
path_fs: null,
|
||||
path_platform_cover: `/api/romm/assets/platforms/${rom.platform_slug}.svg`,
|
||||
source: null,
|
||||
source_id: null
|
||||
};
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
export function convertRomToFrontendDetailed (rom: DetailedRomSchema)
|
||||
{
|
||||
const detailed: FrontEndGameTypeDetailed = {
|
||||
...convertRomToFrontend(rom),
|
||||
summary: rom.summary,
|
||||
fs_size_bytes: rom.fs_size_bytes,
|
||||
paths_screenshots: rom.merged_screenshots.map(s => `/api/romm${s}`),
|
||||
local: false,
|
||||
missing: rom.missing_from_fs
|
||||
};
|
||||
if (rom.merged_ra_metadata?.achievements)
|
||||
{
|
||||
detailed.achievements = {
|
||||
unlocked: rom.merged_ra_metadata.achievements?.map(a => a.num_awarded).length,
|
||||
total: rom.merged_ra_metadata.achievements.length
|
||||
};
|
||||
}
|
||||
return detailed;
|
||||
}
|
||||
180
src/bun/api/jobs/install-job.ts
Normal file
180
src/bun/api/jobs/install-job.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { IJob, JobContext } from "../task-queue";
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import fs from 'node:fs/promises';
|
||||
import { DownloaderHelper } from 'node-downloader-helper';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import * as schema from "../schema/app";
|
||||
import * as emulatorSchema from "../schema/emulators";
|
||||
import path from 'node:path';
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
|
||||
import { config, db, emulatorsDb, jar } from "../app";
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
dryRun?: boolean;
|
||||
dryDownload?: boolean;
|
||||
}
|
||||
|
||||
export class InstallJob implements IJob
|
||||
{
|
||||
public id: number;
|
||||
|
||||
public config?: JobConfig;
|
||||
|
||||
constructor(id: number, config?: JobConfig)
|
||||
{
|
||||
this.id = id;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public async start (cx: JobContext)
|
||||
{
|
||||
cx.setProgress(0, 'download');
|
||||
fs.mkdir(config.get('downloadPath'), { recursive: true });
|
||||
|
||||
if (this.config?.dryRun !== true)
|
||||
{
|
||||
const downloadPath = config.get('downloadPath');
|
||||
|
||||
if (this.config?.dryDownload !== true)
|
||||
{
|
||||
// download files for rom
|
||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.id));
|
||||
const downloader = new DownloaderHelper(downloadUrl.href, downloadPath, {
|
||||
headers: {
|
||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
||||
},
|
||||
fileName: `${this.id}.zip`,
|
||||
// Romm doesn't support resume download
|
||||
override: true
|
||||
});
|
||||
|
||||
cx.abortSignal.addEventListener('abort', downloader.stop);
|
||||
|
||||
downloader.on('progress.throttled', e =>
|
||||
{
|
||||
cx.setProgress(e.progress, 'download');
|
||||
});
|
||||
|
||||
downloader.on('error', (e) =>
|
||||
{
|
||||
cx.abort(e);
|
||||
});
|
||||
const finishPromise = new Promise<string>(resolve =>
|
||||
{
|
||||
downloader.on("end", ({ filePath }) => resolve(filePath));
|
||||
});
|
||||
|
||||
await downloader.start().catch(err => console.error(err));
|
||||
const zipFilePath = await finishPromise;
|
||||
|
||||
cx.setProgress(0, 'extract');
|
||||
|
||||
const zip = new StreamZip.async({ file: zipFilePath });
|
||||
const totalCount = await zip.entriesCount;
|
||||
let extractCount = 0;
|
||||
zip.on('extract', async (entry, file) =>
|
||||
{
|
||||
console.log(`Extracted ${entry.name} to ${file}`);
|
||||
cx.setProgress(extractCount / totalCount * 100, 'extract');
|
||||
extractCount++;
|
||||
});
|
||||
await zip.extract(null, downloadPath);
|
||||
await zip.close();
|
||||
|
||||
await fs.rm(zipFilePath);
|
||||
}
|
||||
|
||||
const rom = (await getRomApiRomsIdGet({ path: { id: this.id }, throwOnError: true })).data;
|
||||
const romPlatform = (await getPlatformApiPlatformsIdGet({ path: { id: rom.platform_id }, throwOnError: true })).data;
|
||||
|
||||
if (this.config?.dryDownload === true)
|
||||
{
|
||||
rom.files.length;
|
||||
await mkdir(path.join(downloadPath, rom.fs_path, rom.fs_name), { recursive: true });
|
||||
}
|
||||
|
||||
// pre-fetch screenshots
|
||||
const screenshots = await Promise.all(rom.merged_screenshots.map(s => fetch(`${config.get('rommAddress')}${s}`)));
|
||||
|
||||
const rommAddress = config.get('rommAddress');
|
||||
const coverResponse = await fetch(`${rommAddress}${rom.path_cover_large}`);
|
||||
|
||||
if (cx.abortSignal.aborted) return;
|
||||
|
||||
await db.transaction(async (tx) =>
|
||||
{
|
||||
// Search for existing platform
|
||||
const platformSearch = [];
|
||||
if (romPlatform.igdb_id) platformSearch.push(eq(schema.platforms.igdb_id, romPlatform.igdb_id));
|
||||
if (romPlatform.igdb_slug) platformSearch.push(eq(schema.platforms.igdb_slug, romPlatform.igdb_slug));
|
||||
if (romPlatform.ra_id) platformSearch.push(eq(schema.platforms.ra_id, romPlatform.ra_id));
|
||||
if (romPlatform.slug) platformSearch.push(eq(schema.platforms.slug, romPlatform.slug));
|
||||
if (romPlatform.moby_id) platformSearch.push(eq(schema.platforms.moby_id, romPlatform.moby_id));
|
||||
|
||||
const esPlatform = await emulatorsDb
|
||||
.select({ slug: emulatorSchema.systems.name, romm_slug: emulatorSchema.systemMappings.sourceSlug })
|
||||
.from(emulatorSchema.systems)
|
||||
.leftJoin(emulatorSchema.systemMappings, eq(emulatorSchema.systemMappings.source, 'romm'))
|
||||
.where(eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug));
|
||||
|
||||
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||
let platformId: number;
|
||||
if (!existingPlatform)
|
||||
{
|
||||
// Create new local platform
|
||||
const platformCover = await fetch(`${rommAddress}/assets/platforms/${romPlatform.slug.toLocaleLowerCase()}.svg`);
|
||||
const platform: typeof schema.platforms.$inferInsert = {
|
||||
slug: romPlatform.slug,
|
||||
igdb_id: romPlatform.igdb_id,
|
||||
igdb_slug: romPlatform.igdb_slug,
|
||||
ra_id: romPlatform.ra_id,
|
||||
cover: Buffer.from(await platformCover.arrayBuffer()),
|
||||
cover_type: platformCover.headers.get('content-type'),
|
||||
name: romPlatform.name,
|
||||
family_name: romPlatform.family_name,
|
||||
es_slug: esPlatform.length > 0 ? esPlatform[0].slug : undefined
|
||||
};
|
||||
// TODO: add ES slug once I have better way to query ES
|
||||
const [{ id }] = await tx.insert(schema.platforms).values(platform).returning({ id: schema.platforms.id });
|
||||
platformId = id;
|
||||
} else
|
||||
{
|
||||
platformId = existingPlatform.id;
|
||||
}
|
||||
|
||||
// create the rom
|
||||
const game: typeof schema.games.$inferInsert = {
|
||||
source_id: rom.id,
|
||||
source: 'romm',
|
||||
slug: rom.slug,
|
||||
path_fs: path.join(rom.fs_path, rom.fs_name),
|
||||
last_played: rom.rom_user.last_played ? new Date(rom.rom_user.last_played) : null,
|
||||
platform_id: platformId,
|
||||
igdb_id: rom.igdb_id,
|
||||
ra_id: rom.ra_id,
|
||||
summary: rom.summary,
|
||||
name: rom.name,
|
||||
cover: Buffer.from(await coverResponse.arrayBuffer()),
|
||||
cover_type: coverResponse.headers.get('content-type')
|
||||
};
|
||||
|
||||
// Save screenshots and update database
|
||||
const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id });
|
||||
await tx.insert(schema.screenshots).values(await Promise.all(screenshots.map(async (response) =>
|
||||
{
|
||||
const screenshot: typeof schema.screenshots.$inferInsert = {
|
||||
game_id: id,
|
||||
content: Buffer.from(await response.arrayBuffer()),
|
||||
type: response.headers.get('content-type')
|
||||
};
|
||||
|
||||
return screenshot;
|
||||
})));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
import { RPC_PORT } from "../../shared/constants";
|
||||
import { settings } from "./settings";
|
||||
import { romm } from "./clients";
|
||||
import Elysia from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import Elysia from "elysia";
|
||||
import { RPC_PORT } from "../../shared/constants";
|
||||
import { host } from "../utils";
|
||||
import clients from "./clients";
|
||||
import { settings } from "./settings";
|
||||
import { system } from "./system";
|
||||
|
||||
const api = new Elysia({ prefix: "/api", serve: {} })
|
||||
.use(cors())
|
||||
.use(romm)
|
||||
.use(settings);
|
||||
const api = new Elysia({ serve: {} })
|
||||
.use([cors(), clients, settings, system]);
|
||||
|
||||
export type AppType = typeof api;
|
||||
export type RommAPIType = typeof clients;
|
||||
export type SettingsAPIType = typeof settings;
|
||||
export type SystemAPIType = typeof system;
|
||||
|
||||
export function RunAPIServer ()
|
||||
{
|
||||
|
|
@ -19,24 +20,11 @@ export function RunAPIServer ()
|
|||
apiServer: api.listen({
|
||||
port: RPC_PORT,
|
||||
hostname: host,
|
||||
development: process.env.NODE_ENV === 'development',
|
||||
fetch (req, server)
|
||||
{
|
||||
if (server.upgrade(req, {
|
||||
data: undefined
|
||||
}))
|
||||
{
|
||||
return;
|
||||
}
|
||||
return api.fetch(req);
|
||||
},
|
||||
websocket: {
|
||||
message (ws, message)
|
||||
{
|
||||
development: process.env.NODE_ENV === 'development'
|
||||
}),
|
||||
async cleanup ()
|
||||
{
|
||||
|
||||
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
54
src/bun/api/schema/app.ts
Normal file
54
src/bun/api/schema/app.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const games = sqliteTable('games', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
source_id: integer('source_id').unique(),
|
||||
source: text("source"),
|
||||
igdb_id: integer("igdb_id").unique(),
|
||||
name: text("name"),
|
||||
ra_id: integer('ra_id').unique(),
|
||||
path_fs: text("path_fs"),
|
||||
last_played: integer("last_played", { mode: 'timestamp' }),
|
||||
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
|
||||
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`),
|
||||
slug: text("slug").unique(),
|
||||
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
|
||||
cover: blob("cover", { mode: 'buffer' }),
|
||||
cover_type: text('type'),
|
||||
summary: text("summary")
|
||||
});
|
||||
|
||||
export const platforms = sqliteTable('platforms', {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
igdb_id: integer("igdb_id").unique(),
|
||||
igdb_slug: text("igdb_slug").unique(),
|
||||
moby_id: integer("moby_id").unique(),
|
||||
name: text("name").notNull(),
|
||||
es_slug: text('es_slug').unique(),
|
||||
ra_id: integer('ra_id').unique(),
|
||||
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
|
||||
slug: text("slug").unique().notNull(),
|
||||
metadata: text("metadata", { mode: 'json' }),
|
||||
cover: blob("cover", { mode: 'buffer' }),
|
||||
cover_type: text('type'),
|
||||
family_name: text("family_name")
|
||||
});
|
||||
|
||||
export const collections_games = sqliteTable('collections_games', {
|
||||
collection_id: integer('collection_id').notNull().references(() => collections.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
game_id: integer('game_id').notNull().references(() => games.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
|
||||
});
|
||||
|
||||
export const collections = sqliteTable('collections', {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text('name')
|
||||
});
|
||||
|
||||
export const screenshots = sqliteTable('screenshots', {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
game_id: integer('game_id').references(() => games.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
content: blob('content', { mode: 'buffer' }).notNull(),
|
||||
type: text('type')
|
||||
});
|
||||
43
src/bun/api/schema/emulators.ts
Normal file
43
src/bun/api/schema/emulators.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { relations, sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const emulators = sqliteTable('emulators', {
|
||||
name: text().primaryKey().unique(),
|
||||
systempath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
staticpath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
corepath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
androidpackage: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
winregistrypath: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`),
|
||||
});
|
||||
|
||||
export const systems = sqliteTable('systems', {
|
||||
name: text().primaryKey().unique(),
|
||||
fullname: text(),
|
||||
path: text(),
|
||||
extension: text({ mode: 'json' }).notNull().$type<string[]>().default(sql`(json_array())`)
|
||||
});
|
||||
|
||||
export const systemsRelations = relations(systems, ({ many }) =>
|
||||
({
|
||||
commands: many(commands)
|
||||
}));
|
||||
|
||||
export const systemMappings = sqliteTable('systemMappings', {
|
||||
source: text(),
|
||||
sourceSlug: text(),
|
||||
sourceId: integer(),
|
||||
system: text().notNull().references(() => systems.name)
|
||||
});
|
||||
|
||||
export const commands = sqliteTable('commands', {
|
||||
system: text().references(() => systems.name, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
label: text(),
|
||||
command: text().notNull()
|
||||
});
|
||||
|
||||
export const commandsRelations = relations(commands, ({ one }) => ({
|
||||
author: one(systems, {
|
||||
fields: [commands.system],
|
||||
references: [systems.name],
|
||||
}),
|
||||
}));
|
||||
133
src/bun/api/secrets.ts
Normal file
133
src/bun/api/secrets.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import Conf from "conf";
|
||||
import projectPackage from '~/package.json';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
|
||||
let secrets: ISecrets;
|
||||
|
||||
interface ISecrets
|
||||
{
|
||||
set (data: { service: string, name: string, value: string; }): Promise<void>;
|
||||
get (data: { service: string, name: string; }): Promise<string | null>;
|
||||
delete (data: { service: string, name: string; }): Promise<boolean>;
|
||||
}
|
||||
|
||||
class BunSecrets implements ISecrets
|
||||
{
|
||||
public set (data: { service: string, name: string, value: string; })
|
||||
{
|
||||
return Bun.secrets.set(data);
|
||||
}
|
||||
|
||||
public get (data: { service: string, name: string; })
|
||||
{
|
||||
return Bun.secrets.get(data);
|
||||
}
|
||||
|
||||
public delete (data: { service: string, name: string; })
|
||||
{
|
||||
return Bun.secrets.delete(data);
|
||||
}
|
||||
}
|
||||
|
||||
class FallbackSecrets implements ISecrets
|
||||
{
|
||||
config: Conf<Record<string, string>>;
|
||||
machineKey?: Buffer<ArrayBufferLike>;
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.config = new Conf<Record<string, string>>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
configFileMode: 0o600,
|
||||
configName: 'secrets'
|
||||
});
|
||||
console.log("Secrets Store Located at: ", this.config.path);
|
||||
}
|
||||
|
||||
async getMachineKey ()
|
||||
{
|
||||
if (!this.machineKey)
|
||||
{
|
||||
|
||||
let raw: string;
|
||||
try
|
||||
{
|
||||
raw = await fs.readFile("/etc/machine-id", 'utf-8');
|
||||
} catch (error)
|
||||
{
|
||||
raw = [
|
||||
os.homedir(),
|
||||
os.userInfo().username,
|
||||
os.platform(),
|
||||
os.arch(),
|
||||
os.cpus().map(c => c.model).join(','),
|
||||
String(os.totalmem())
|
||||
].filter(Boolean).join("|");
|
||||
}
|
||||
this.machineKey = crypto.createHash('sha256').update(raw.trim()).digest();
|
||||
}
|
||||
|
||||
return this.machineKey;
|
||||
}
|
||||
|
||||
public async set ({ service, name, value }: { service: string, name: string, value: string; })
|
||||
{
|
||||
const iv = crypto.randomBytes(16);
|
||||
const key = await this.getMachineKey();
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
iv,
|
||||
cipher.update(value, "utf-8"),
|
||||
cipher.final()
|
||||
]);
|
||||
return this.config.set(`${service}-${name}`, encrypted.toString('base64'));
|
||||
}
|
||||
|
||||
public async get ({ service, name }: { service: string, name: string; })
|
||||
{
|
||||
const rawBase = this.config.get(`${service}-${name}`);
|
||||
if (!rawBase)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
const key = await this.getMachineKey();
|
||||
const raw = Buffer.from(rawBase, 'base64');
|
||||
|
||||
const iv = raw.subarray(0, 16);
|
||||
const ciphertext = raw.subarray(16);
|
||||
const cipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
const data = Buffer.concat([cipher.update(ciphertext), cipher.final()]).toString("utf-8");
|
||||
|
||||
return data;
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete ({ service, name }: { service: string, name: string; })
|
||||
{
|
||||
this.config.delete(`${service}-${name}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
try
|
||||
{
|
||||
await Bun.secrets.get({ service: 'test', name: 'test' });
|
||||
secrets = new BunSecrets();
|
||||
} catch
|
||||
{
|
||||
secrets = new FallbackSecrets();
|
||||
}*/
|
||||
|
||||
secrets = new FallbackSecrets();
|
||||
|
||||
export default secrets;
|
||||
|
|
@ -1,18 +1,95 @@
|
|||
import z from "zod";
|
||||
import { SettingsSchema, SettingsType } from "../../shared/constants";
|
||||
import Conf from "conf";
|
||||
import projectPackage from '../../../package.json';
|
||||
import { SettingsSchema } from "@shared/constants";
|
||||
import Elysia from "elysia";
|
||||
import { config, customEmulators, db, emulatorsDb } from "./app";
|
||||
import * as appSchema from './schema/app';
|
||||
import { findExec } from "./games/services/launchGameService";
|
||||
import * as emulatorSchema from "./schema/emulators";
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
projectSuffix: 'bun',
|
||||
schema: Object.fromEntries(Object.entries(SettingsSchema.shape).map(([key, schema]) => [key, schema.toJSONSchema() as any])) as any,
|
||||
defaults: SettingsSchema.parse({}),
|
||||
});
|
||||
console.log("Config Path Located At: ", config.path);
|
||||
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||
.get('/emulators/automatic', async () =>
|
||||
{
|
||||
const localGames = await db.select({ es_slug: appSchema.platforms.es_slug, platform_id: appSchema.platforms.id })
|
||||
.from(appSchema.games)
|
||||
.leftJoin(appSchema.platforms, eq(appSchema.games.platform_id, appSchema.platforms.id))
|
||||
.groupBy(appSchema.platforms.es_slug);
|
||||
|
||||
export const settings = new Elysia({ prefix: '/settings' })
|
||||
const platformLookup = new Map(localGames.map(g => [g.es_slug, g.platform_id]));
|
||||
|
||||
const commands = await emulatorsDb
|
||||
.select({ command: emulatorSchema.commands.command, system_slug: emulatorSchema.systems.name })
|
||||
.from(emulatorSchema.commands).where(inArray(emulatorSchema.commands.system, Array.from(new Set(localGames.filter(g => g.es_slug).map(s => s.es_slug!)))))
|
||||
.leftJoin(emulatorSchema.systems, eq(emulatorSchema.systems.name, emulatorSchema.commands.system));
|
||||
|
||||
|
||||
const emulatorCounts: Record<string, number> = {};
|
||||
const emulators = commands
|
||||
.flatMap(command =>
|
||||
{
|
||||
const matches = command.command.match(/(?<=%EMULATOR_)[\w-]+(?=%)/);
|
||||
if (!matches)
|
||||
{
|
||||
return undefined;
|
||||
}
|
||||
|
||||
matches.forEach(m =>
|
||||
{
|
||||
emulatorCounts[m] = (emulatorCounts[m] ?? 0) + 1;
|
||||
});
|
||||
|
||||
return matches?.map(m => [m, command.system_slug] as [string, string]);
|
||||
}
|
||||
).filter(c => !!c);
|
||||
const uniqueEmulators = new Map(emulators);
|
||||
|
||||
return await Promise.all(Array.from(uniqueEmulators.entries()).map(async ([emulator, system_slug]) =>
|
||||
{
|
||||
let execPath: string | undefined;
|
||||
if (customEmulators.has(emulator))
|
||||
{
|
||||
execPath = customEmulators.get(emulator);
|
||||
} else
|
||||
{
|
||||
execPath = await findExec(emulator);
|
||||
}
|
||||
|
||||
let platform: number | null | undefined = null;
|
||||
if (emulatorCounts[emulator] <= 1)
|
||||
{
|
||||
platform = platformLookup.get(system_slug);
|
||||
}
|
||||
|
||||
return { emulator: emulator, path: execPath, exists: !!execPath && await fs.exists(execPath), path_cover: platform ? `/api/romm/platform/local/${platform}/cover` : null };
|
||||
}));
|
||||
}, {
|
||||
response: z.array(z.object({ emulator: z.string(), path: z.string().optional(), exists: z.boolean(), path_cover: z.string().nullable() }))
|
||||
})
|
||||
.put('/emulators/custom/:id', async ({ params: { id }, body: { value } }) =>
|
||||
{
|
||||
return customEmulators.set(id, value);
|
||||
},
|
||||
{
|
||||
body: z.object({ value: z.string() })
|
||||
})
|
||||
.delete('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.delete(id);
|
||||
})
|
||||
.get('/emulators/custom/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return customEmulators.get(id);
|
||||
},
|
||||
{
|
||||
response: z.string()
|
||||
})
|
||||
.get('/emulators/custom', async () =>
|
||||
{
|
||||
return Object.keys(customEmulators.store);
|
||||
}, {
|
||||
response: z.array(z.string())
|
||||
})
|
||||
.get("/:id", async ({ params: { id } }) =>
|
||||
{
|
||||
const value = config.get(id);
|
||||
|
|
@ -25,5 +102,6 @@ export const settings = new Elysia({ prefix: '/settings' })
|
|||
config.set(id, value);
|
||||
}, {
|
||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||
body: z.object({ value: z.any() })
|
||||
body: z.object({ value: z.any() }),
|
||||
});
|
||||
|
||||
|
|
|
|||
43
src/bun/api/system.ts
Normal file
43
src/bun/api/system.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import Elysia from "elysia";
|
||||
import open from 'open';
|
||||
import z from "zod";
|
||||
import os from 'node:os';
|
||||
import { events } from "./app";
|
||||
import { isSteamDeckGameMode } from "../utils";
|
||||
|
||||
// steam://open/keyboard?XPosition=%i&YPosition=%i&Width=%i&Height=%i&Mode=%d
|
||||
export const system = new Elysia({ prefix: '/api/system' })
|
||||
.post('/show_keyboard', async () =>
|
||||
{
|
||||
if (isSteamDeckGameMode())
|
||||
{
|
||||
open('steam://open/keyboard');
|
||||
}
|
||||
})
|
||||
.get('/info', () =>
|
||||
{
|
||||
return {
|
||||
homeDir: os.homedir(),
|
||||
user: os.userInfo().username,
|
||||
arch: os.arch(),
|
||||
platform: os.platform(),
|
||||
hostname: os.hostname(),
|
||||
steamDeck: process.env.SteamDeck,
|
||||
machine: os.machine()
|
||||
};
|
||||
})
|
||||
.post('/exit', () =>
|
||||
{
|
||||
if (process.env.PUBLIC_ACCESS)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
events.emit('exitapp');
|
||||
})
|
||||
.post('/open', async ({ query: { url } }) =>
|
||||
{
|
||||
open(url);
|
||||
}, {
|
||||
query: z.object({ url: z.url() })
|
||||
});
|
||||
207
src/bun/api/task-queue.ts
Normal file
207
src/bun/api/task-queue.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
|
||||
import EventEmitter from 'node:events';
|
||||
|
||||
export class TaskQueue
|
||||
{
|
||||
private activeQueue: { context: JobContext, promise?: Promise<void>; }[] = [];
|
||||
private queue?: { context: JobContext, promise?: Promise<void>; }[] = [];
|
||||
private events?: EventEmitter<EventsList> = new EventEmitter<EventsList>();
|
||||
|
||||
public enqueue (id: string, job: IJob): Promise<void>
|
||||
{
|
||||
this.disposeSafeguard();
|
||||
if (!this.queue || !this.events) throw new Error("Queue disposed");
|
||||
const context = new JobContext(id, this.events, job);
|
||||
this.queue.push({ context });
|
||||
return this.processQueue();
|
||||
}
|
||||
|
||||
private processQueue (): Promise<void>
|
||||
{
|
||||
if (!this.queue) return Promise.resolve();
|
||||
const top = this.queue.pop();
|
||||
if (top)
|
||||
{
|
||||
const promise = top.context.start();
|
||||
top.promise = promise;
|
||||
const index = this.queue.length;
|
||||
this.activeQueue.push(top);
|
||||
promise.finally(() =>
|
||||
{
|
||||
this.activeQueue.splice(index, 1);
|
||||
setTimeout(this.processQueue);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private disposeSafeguard ()
|
||||
{
|
||||
if (!this.queue) throw new Error("Queue disposed");
|
||||
}
|
||||
|
||||
public hasActive ()
|
||||
{
|
||||
return this.activeQueue.length > 0;
|
||||
}
|
||||
|
||||
public waitForJob (id: string): Promise<void>
|
||||
{
|
||||
return this.queue?.find(j => j.context.id === id)?.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public findJob (id: string): IPublicJob | undefined
|
||||
{
|
||||
return this.queue?.find(j => j.context.id === id)?.context;
|
||||
}
|
||||
|
||||
public on<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never): () => void
|
||||
{
|
||||
this.events?.on(event, listener);
|
||||
return () => this.events?.removeListener(event, listener);
|
||||
}
|
||||
|
||||
public once<E extends keyof EventsList> (event: E, listener: E extends keyof EventsList ? EventsList[E] extends unknown[] ? (...args: EventsList[E]) => void : never : never)
|
||||
{
|
||||
this.events?.once(event, listener);
|
||||
}
|
||||
|
||||
public async close ()
|
||||
{
|
||||
this.queue = [];
|
||||
this.activeQueue.forEach(c => c.context.abort());
|
||||
return Promise.all(this.activeQueue.map(c => c.promise));
|
||||
}
|
||||
}
|
||||
|
||||
export interface EventsList
|
||||
{
|
||||
progress: [e: ProgressEvent];
|
||||
abort: [e: AbortEvent];
|
||||
completed: [e: CompletedEvent];
|
||||
error: [e: ErrorEvent];
|
||||
ended: [e: BaseEvent];
|
||||
}
|
||||
|
||||
interface BaseEvent
|
||||
{
|
||||
id: string;
|
||||
job: IJob;
|
||||
}
|
||||
|
||||
interface ErrorEvent extends BaseEvent
|
||||
{
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
interface AbortEvent extends BaseEvent
|
||||
{
|
||||
reason?: any;
|
||||
}
|
||||
|
||||
interface ProgressEvent extends BaseEvent
|
||||
{
|
||||
progress: number;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
interface CompletedEvent extends BaseEvent
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
export interface IJob
|
||||
{
|
||||
start (context: JobContext): Promise<any>;
|
||||
}
|
||||
|
||||
export type JobStatus = 'completed' | 'error' | 'running' | 'waiting' | 'aborted';
|
||||
|
||||
export interface IPublicJob
|
||||
{
|
||||
progress: number;
|
||||
state?: string;
|
||||
status: JobStatus;
|
||||
job: any;
|
||||
}
|
||||
|
||||
export class JobContext implements IPublicJob
|
||||
{
|
||||
private m_id: string;
|
||||
private m_progress: number = 0;
|
||||
private m_state?: string;
|
||||
private running: boolean = false;
|
||||
private aborted: boolean = false;
|
||||
private completed: boolean = false;
|
||||
private error?: any;
|
||||
private events: EventEmitter<EventsList>;
|
||||
private abortController: AbortController;
|
||||
private m_job: IJob;
|
||||
|
||||
constructor(id: string, events: EventEmitter<EventsList>, job: IJob)
|
||||
{
|
||||
this.m_id = id;
|
||||
this.m_job = job;
|
||||
this.abortController = new AbortController();
|
||||
this.abortController.signal.addEventListener('abort', () =>
|
||||
{
|
||||
this.aborted = true;
|
||||
this.events.emit('abort', { id: this.m_id, reason: this.abortController.signal.reason, job: this.m_job } satisfies AbortEvent);
|
||||
});
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
public async start (): Promise<void>
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.m_job.start(this);
|
||||
this.completed = true;
|
||||
this.events.emit('completed', { id: this.m_id, job: this.m_job });
|
||||
|
||||
} catch (error)
|
||||
{
|
||||
console.error(error);
|
||||
this.events.emit('error', { id: this.m_id, error });
|
||||
this.error = error;
|
||||
} finally
|
||||
{
|
||||
this.running = false;
|
||||
this.events.emit('ended', { id: this.m_id, job: this.m_job });
|
||||
}
|
||||
}
|
||||
|
||||
public get status (): JobStatus
|
||||
{
|
||||
if (this.completed) return 'completed';
|
||||
if (this.error) return 'error';
|
||||
if (this.aborted) return 'aborted';
|
||||
if (this.running) return 'running';
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
public get id () { return this.m_id; }
|
||||
|
||||
public get job () { return this.m_job; }
|
||||
|
||||
public get abortSignal () { return this.abortController.signal; }
|
||||
|
||||
public get progress () { return this.m_progress; }
|
||||
|
||||
public get state () { return this.m_state; }
|
||||
|
||||
public setProgress (progress: number, state?: string)
|
||||
{
|
||||
this.m_progress = progress;
|
||||
if (state)
|
||||
this.m_state = state;
|
||||
this.events.emit('progress', { id: this.m_id, progress, state: state ?? this.m_state, job: this.m_job });
|
||||
}
|
||||
|
||||
public abort (reason?: any)
|
||||
{
|
||||
this.error = reason;
|
||||
this.abortController.abort(reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import { RunBunServer } from './server';
|
|||
import { RunAPIServer } from './api/rpc';
|
||||
import { spawnBrowser } from './utils/browser-spawner';
|
||||
import { BuildParams } from './utils/browser-params';
|
||||
import { cleanup as appCleanup, events } from './api/app';
|
||||
import os from 'node:os';
|
||||
|
||||
const api = RunAPIServer();
|
||||
let bunServer: { stop: () => void; url: URL; } | undefined;
|
||||
|
|
@ -13,43 +15,80 @@ if (!Bun.env.PUBLIC_ACCESS)
|
|||
|
||||
async function cleanup ()
|
||||
{
|
||||
await appCleanup();
|
||||
bunServer?.stop();
|
||||
await api.apiServer.stop();
|
||||
await api.cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try
|
||||
if (Bun.env.FORCE_BROWSER)
|
||||
{
|
||||
const webviewWorker = new Worker(process.env.IS_BINARY ? "./webview-worker.ts" : new URL("./webview-worker", import.meta.url).href, {
|
||||
await runBrowser();
|
||||
} else
|
||||
{
|
||||
try
|
||||
{
|
||||
await runWebview();
|
||||
} catch (error)
|
||||
{
|
||||
await runBrowser();
|
||||
}
|
||||
}
|
||||
|
||||
async function runWebview ()
|
||||
{
|
||||
const webviewWorker = new Worker(Bun.env.IS_BINARY ? `./webview/${os.platform()}.ts` : new URL(`./webview/${os.platform()}`, import.meta.url).href, {
|
||||
smol: true,
|
||||
});
|
||||
webviewWorker.addEventListener('error', console.error);
|
||||
await new Promise(resolve => webviewWorker.addEventListener('close', resolve));
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
webviewWorker.addEventListener('error', e =>
|
||||
{
|
||||
console.error(e.message);
|
||||
reject(e.error);
|
||||
});
|
||||
|
||||
webviewWorker.addEventListener('message', (e) =>
|
||||
{
|
||||
if (e.data === 'destroyed')
|
||||
{
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
events.on('exitapp', () =>
|
||||
{
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
await cleanup();
|
||||
}
|
||||
catch (error)
|
||||
|
||||
async function runBrowser ()
|
||||
{
|
||||
console.error(error);
|
||||
|
||||
const browserParams = await BuildParams();
|
||||
|
||||
if (!browserParams)
|
||||
{
|
||||
console.error("Could not find valid browser");
|
||||
process.exit();
|
||||
}
|
||||
await cleanup();
|
||||
} else
|
||||
{
|
||||
const browser = spawnBrowser({
|
||||
browser: browserParams.browser.type,
|
||||
args: browserParams.args,
|
||||
env: browserParams.env,
|
||||
detached: false,
|
||||
execPath: browserParams.browser.path,
|
||||
source: browserParams.browser.source,
|
||||
ipc (message)
|
||||
{
|
||||
console.log(message);
|
||||
},
|
||||
onExit: cleanup
|
||||
});
|
||||
|
||||
const browser = spawnBrowser({
|
||||
browser: browserParams.browser.type,
|
||||
args: browserParams.args,
|
||||
env: browserParams.env,
|
||||
detached: true,
|
||||
execPath: browserParams.browser.path,
|
||||
source: browserParams.browser.source,
|
||||
ipc (message)
|
||||
{
|
||||
console.log(message);
|
||||
},
|
||||
onExit: cleanup
|
||||
});
|
||||
events.on('exitapp', () => browser.kill(15));
|
||||
}
|
||||
}
|
||||
19
src/bun/types.d.ts
vendored
19
src/bun/types.d.ts
vendored
|
|
@ -1,19 +0,0 @@
|
|||
declare const IS_BINARY: string;
|
||||
|
||||
declare module 'download-chromium' {
|
||||
export default function download ({
|
||||
platform,
|
||||
revision = '499413',
|
||||
log = false,
|
||||
onProgress = undefined,
|
||||
installPath = '{__dirname}/.local-chromium' }: {
|
||||
platform?: 'linux' | 'mac' | 'win32' | 'win64',
|
||||
revision?: string,
|
||||
log?: boolean,
|
||||
installPath?: string,
|
||||
onProgress?: (percent: number, transferred: number, total: number) => void;
|
||||
}): Promise<string>
|
||||
{
|
||||
|
||||
};
|
||||
}
|
||||
8
src/bun/types/types.d.ts
vendored
Normal file
8
src/bun/types/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
declare const IS_BINARY: string;
|
||||
|
||||
export type ActiveGame = {
|
||||
process: Bun.Subprocess;
|
||||
gameId: number;
|
||||
name: string;
|
||||
command: string;
|
||||
};
|
||||
|
|
@ -16,4 +16,15 @@ export function checkRunning (pid: number)
|
|||
{
|
||||
return error.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage (error: unknown): string
|
||||
{
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function isSteamDeckGameMode ()
|
||||
{
|
||||
return !!Bun.env.SteamDeck;
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ import { SERVER_URL } from "../../shared/constants";
|
|||
import os from 'node:os';
|
||||
import path, { dirname } from 'node:path';
|
||||
import { getBrowserPath } from "./get-browser";
|
||||
import { config } from "../api/settings";
|
||||
import { host } from "../utils";
|
||||
import { host, isSteamDeckGameMode } from "../utils";
|
||||
import { config } from "../api/app";
|
||||
|
||||
export async function BuildParams ()
|
||||
{
|
||||
|
|
@ -42,7 +42,15 @@ export async function BuildParams ()
|
|||
args.push('--disable-component-update');
|
||||
args.push('--allow-insecure-localhost');
|
||||
args.push('--auto-accept-camera-and-microphone-capture');
|
||||
args.push(`--window-size=${config.get('windowSize.width')},${config.get('windowSize.height')}`);
|
||||
|
||||
if (isSteamDeckGameMode())
|
||||
{
|
||||
args.push('--kiosk');
|
||||
} else
|
||||
{
|
||||
args.push(`--window-size=${config.get('windowSize.width')},${config.get('windowSize.height')}`);
|
||||
}
|
||||
|
||||
args.push('--password-store=basic');
|
||||
args.push('--block-new-web-contents');
|
||||
args.push('--bwsi');
|
||||
|
|
@ -82,8 +90,8 @@ export async function BuildParams ()
|
|||
|
||||
if (os.platform() === 'linux')
|
||||
{
|
||||
args.push("--disable-web-security");
|
||||
args.push("--no-sandbox");
|
||||
//args.push("--disable-web-security");
|
||||
//args.push("--no-sandbox");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
17
src/bun/webview/base.ts
Normal file
17
src/bun/webview/base.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { SERVER_URL } from "@/shared/constants";
|
||||
import Webview from "@rcompat/webview";
|
||||
import { host } from "../utils";
|
||||
|
||||
export default function (webview: Webview)
|
||||
{
|
||||
self.addEventListener('message', (e) =>
|
||||
{
|
||||
console.log("Terminate");
|
||||
if (e.data === 'exit')
|
||||
{
|
||||
webview.destroy();
|
||||
}
|
||||
});
|
||||
webview.navigate(SERVER_URL(host));
|
||||
webview.run();
|
||||
}
|
||||
7
src/bun/webview/linux.ts
Normal file
7
src/bun/webview/linux.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Webview from "@rcompat/webview";
|
||||
import platform from "@rcompat/webview/linux-x64";
|
||||
import webviewWorkerBase from "./base";
|
||||
|
||||
console.log("Launching Webview");
|
||||
const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform });
|
||||
webviewWorkerBase(webview);
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import Webview from "@rcompat/webview";
|
||||
import platform from "@rcompat/webview/windows-x64";
|
||||
import { SERVER_URL } from "../shared/constants";
|
||||
import { host } from "./utils";
|
||||
import webviewWorkerBase from "./base";
|
||||
|
||||
console.log("Launching Webview");
|
||||
const webview = new Webview({ debug: import.meta.env.NODE_ENV === 'development', platform });
|
||||
webview.navigate(SERVER_URL(host));
|
||||
webview.run();
|
||||
webviewWorkerBase(webview);
|
||||
Loading…
Add table
Add a link
Reference in a new issue