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:
parent
62f16cbcc1
commit
e4df8fb9fb
55 changed files with 1675 additions and 398 deletions
|
|
@ -19,6 +19,7 @@ import { ActiveGame } from "../types/types";
|
|||
import EventEmitter from "node:events";
|
||||
import { ErrorLike } from "bun";
|
||||
import { getErrorMessage } from "../utils";
|
||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
|
|
@ -44,9 +45,10 @@ const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path),
|
|||
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" });
|
||||
let sqlite: Database;
|
||||
export let db: DrizzleSqliteDODatabase<typeof schema>;
|
||||
await reloadDatabase();
|
||||
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();
|
||||
|
|
@ -77,9 +79,15 @@ export async function cleanup ()
|
|||
emulatorsSqlite.close();
|
||||
}
|
||||
|
||||
export async function reloadDatabase ()
|
||||
{
|
||||
sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true });
|
||||
db = drizzle(sqlite, { schema });
|
||||
}
|
||||
|
||||
interface AppEventMap
|
||||
{
|
||||
activegameexit: [{ subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
activegameexit: [{ source: string, id: number, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
exitapp: [];
|
||||
notification: [Notification];
|
||||
}
|
||||
91
src/bun/api/drives.ts
Normal file
91
src/bun/api/drives.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { Drive } from "@/shared/constants";
|
||||
import si from 'systeminformation';
|
||||
import fs from 'node:fs';
|
||||
import os from "node:os";
|
||||
|
||||
async function getAccess (path: string)
|
||||
{
|
||||
let hasWriteAccess = false;
|
||||
try
|
||||
{
|
||||
await fs.promises.access(path, fs.constants.W_OK);
|
||||
hasWriteAccess = true;
|
||||
} catch (error)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
let hasReadAccesss = false;
|
||||
try
|
||||
{
|
||||
await fs.promises.access(path, fs.constants.R_OK);
|
||||
hasReadAccesss = true;
|
||||
} catch (error)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
return [hasReadAccesss, hasWriteAccess];
|
||||
}
|
||||
|
||||
export async function getDevices (): Promise<Drive[]>
|
||||
{
|
||||
const blockDevicesRaw = await si.blockDevices();
|
||||
const layout = await si.diskLayout();
|
||||
const blockDevices = blockDevicesRaw.filter(l => l.device && l.type === 'part' && l.mount);
|
||||
const fsSizes = await si.fsSize();
|
||||
const sizes = new Map(fsSizes.map(s => [s.mount, s]));
|
||||
const layoutMap = new Map(layout.map(l => [l.device, l]));
|
||||
return await Promise.all(blockDevices.map(async l =>
|
||||
{
|
||||
const size = sizes.get(l.mount);
|
||||
const layout = layoutMap.get(l.device!);
|
||||
const [hasReadAccess, hasWriteAccess] = await getAccess(l.mount);
|
||||
const drive: Drive = {
|
||||
parent: l.group || null,
|
||||
device: l.device ?? '',
|
||||
label: l.label || l.name,
|
||||
mountPoint: l.mount,
|
||||
type: l.type as any,
|
||||
size: l.size,
|
||||
used: size?.used ?? l.size,
|
||||
isRemovable: l.removable,
|
||||
interfaceType: layout?.interfaceType || null,
|
||||
hasReadAccess,
|
||||
hasWriteAccess
|
||||
};
|
||||
return drive;
|
||||
}));
|
||||
}
|
||||
|
||||
// Gets hand picked locations on drives that you have permission to write to
|
||||
export async function getDevicesCurated (): Promise<Drive[]>
|
||||
{
|
||||
const drives: Drive[] = [];
|
||||
const devices = await getDevices();
|
||||
drives.push(...devices.filter(d => d.hasWriteAccess));
|
||||
|
||||
const homeDir = os.homedir();
|
||||
const homeDirDevice = devices.filter(d => d.mountPoint).reverse()
|
||||
.find(d => homeDir.startsWith(d.mountPoint!));
|
||||
if (homeDirDevice)
|
||||
{
|
||||
const [hasReadAccess, hasWriteAccess] = await getAccess(homeDir);
|
||||
|
||||
drives.push({
|
||||
parent: homeDirDevice.parent,
|
||||
device: homeDirDevice.device,
|
||||
size: homeDirDevice.size,
|
||||
used: homeDirDevice.used,
|
||||
isRemovable: homeDirDevice.isRemovable,
|
||||
mountPoint: homeDir,
|
||||
type: homeDirDevice.type,
|
||||
label: 'Home',
|
||||
interfaceType: homeDirDevice.interfaceType,
|
||||
hasReadAccess,
|
||||
hasWriteAccess
|
||||
});
|
||||
}
|
||||
|
||||
return drives;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,16 +2,13 @@ import { IJob, JobContext } from "../task-queue";
|
|||
import { mkdir } from 'node:fs/promises';
|
||||
import { and, 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 { downloadRomsApiRomsDownloadGet, getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
|
||||
import { config, db, emulatorsDb, jar } from "../app";
|
||||
import unzip from 'unzip-stream';
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { createWriteStream } from "node:fs";
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -22,13 +19,17 @@ interface JobConfig
|
|||
export class InstallJob implements IJob
|
||||
{
|
||||
public id: number;
|
||||
public source: string;
|
||||
public sourceId: number;
|
||||
|
||||
public config?: JobConfig;
|
||||
|
||||
constructor(id: number, config?: JobConfig)
|
||||
constructor(id: number, source: string, sourceId: number, config?: JobConfig)
|
||||
{
|
||||
this.id = id;
|
||||
this.config = config;
|
||||
this.sourceId = sourceId;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public async start (cx: JobContext)
|
||||
|
|
|
|||
|
|
@ -3,16 +3,33 @@ import { events } from './app';
|
|||
|
||||
export default function buildNotificationsStream ()
|
||||
{
|
||||
let closed = false;
|
||||
let cleanup: (() => void) | undefined = undefined;
|
||||
return new ReadableStream({
|
||||
async start (controller)
|
||||
{
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
function enqueue (data: Notification, event?: 'notification')
|
||||
{
|
||||
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`));
|
||||
}
|
||||
|
||||
// seems to help with issue of buffers not flushing, keeping the connection open forcefully
|
||||
const keepAlive = setInterval(() =>
|
||||
{
|
||||
if (closed) return clearInterval(keepAlive);
|
||||
try
|
||||
{
|
||||
controller.enqueue(encoder.encode(`: ping\n\n`));
|
||||
} catch
|
||||
{
|
||||
closed = true;
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const notificationHandler = (notification: Notification) =>
|
||||
{
|
||||
enqueue(notification, 'notification');
|
||||
|
|
@ -23,6 +40,7 @@ export default function buildNotificationsStream ()
|
|||
cancel: () =>
|
||||
{
|
||||
cleanup?.();
|
||||
closed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
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";
|
||||
import { host } from "../utils/host";
|
||||
|
||||
const api = new Elysia({ serve: {} })
|
||||
.use([cors(), clients, settings, system]);
|
||||
|
|
|
|||
|
|
@ -118,7 +118,6 @@ class FallbackSecrets implements ISecrets
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
try
|
||||
{
|
||||
await Bun.secrets.get({ service: 'test', name: 'test' });
|
||||
|
|
@ -126,8 +125,6 @@ try
|
|||
} catch
|
||||
{
|
||||
secrets = new FallbackSecrets();
|
||||
}*/
|
||||
|
||||
secrets = new FallbackSecrets();
|
||||
}
|
||||
|
||||
export default secrets;
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
import z from "zod";
|
||||
import { SettingsSchema } from "@shared/constants";
|
||||
import Elysia from "elysia";
|
||||
import { config, customEmulators, db, emulatorsDb } from "./app";
|
||||
import Elysia, { status } from "elysia";
|
||||
import { config, customEmulators, db, emulatorsDb, taskQueue } 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';
|
||||
import { existsSync } from "node:fs";
|
||||
import { InstallJob } from "./jobs/install-job";
|
||||
import { move } from "fs-extra";
|
||||
|
||||
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||
.get('/emulators/automatic', async () =>
|
||||
|
|
@ -90,6 +93,46 @@ export const settings = new Elysia({ prefix: '/api/settings' })
|
|||
}, {
|
||||
response: z.array(z.string())
|
||||
})
|
||||
.put('/path/download', async ({ body: { manualPath, drive } }) =>
|
||||
{
|
||||
if (taskQueue.hasActiveOfType(InstallJob))
|
||||
{
|
||||
return status("Forbidden", "Installation in progress");
|
||||
}
|
||||
|
||||
const oldDownloadPath = config.get('downloadPath');
|
||||
if (!existsSync(oldDownloadPath))
|
||||
{
|
||||
return status("Not Found", "Old downlod path doesn't exist");
|
||||
}
|
||||
|
||||
async function isDirEmpty (dirname: string)
|
||||
{
|
||||
const files = await fs.readdir(dirname);
|
||||
return files.length === 0;
|
||||
}
|
||||
|
||||
const path = manualPath ?? drive;
|
||||
|
||||
if (!path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(path) && !isDirEmpty(path))
|
||||
{
|
||||
return status("Conflict", "New location alaready exists and is not empty");
|
||||
}
|
||||
|
||||
await move(oldDownloadPath, path);
|
||||
config.set('downloadPath', manualPath);
|
||||
return manualPath;
|
||||
}, {
|
||||
body: z.object({
|
||||
manualPath: z.string().optional(),
|
||||
drive: z.string().optional()
|
||||
})
|
||||
})
|
||||
.get("/:id", async ({ params: { id } }) =>
|
||||
{
|
||||
const value = config.get(id);
|
||||
|
|
|
|||
|
|
@ -3,24 +3,38 @@ import open from 'open';
|
|||
import z from "zod";
|
||||
import os from 'node:os';
|
||||
import { config, events } from "./app";
|
||||
import { isSteamDeckGameMode } from "../utils";
|
||||
import { isSteamDeck, openExternal } from "../utils";
|
||||
import fs from 'node:fs/promises';
|
||||
import buildNotificationsStream from "./notifications";
|
||||
import path, { dirname } from "node:path";
|
||||
import { DirSchema, DownloadsDrive } from "@/shared/constants";
|
||||
import { getDevices, getDevicesCurated } from "./drives";
|
||||
import getFolderSize from "get-folder-size";
|
||||
import si from 'systeminformation';
|
||||
|
||||
// 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 () =>
|
||||
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
||||
{
|
||||
if (isSteamDeckGameMode())
|
||||
if (await isSteamDeck())
|
||||
{
|
||||
open('steam://open/keyboard');
|
||||
const url = new URL('steam://open/keyboard');
|
||||
if (XPosition) url.searchParams.set('XPosition', String(XPosition));
|
||||
if (YPosition) url.searchParams.set('YPosition', String(YPosition));
|
||||
if (Width) url.searchParams.set('Width', String(Width));
|
||||
if (Height) url.searchParams.set('Height', String(Height));
|
||||
open(url.href);
|
||||
}
|
||||
}, {
|
||||
body: z.object({
|
||||
XPosition: z.coerce.number().optional(),
|
||||
YPosition: z.coerce.number().optional(),
|
||||
Width: z.coerce.number().optional(),
|
||||
Height: z.coerce.number().optional()
|
||||
})
|
||||
})
|
||||
.get('/info', async () =>
|
||||
{
|
||||
|
||||
const downloadStats = await fs.statfs(config.get('downloadPath'));
|
||||
|
||||
return {
|
||||
homeDir: os.homedir(),
|
||||
user: os.userInfo().username,
|
||||
|
|
@ -29,9 +43,6 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
hostname: os.hostname(),
|
||||
steamDeck: process.env.SteamDeck,
|
||||
machine: os.machine(),
|
||||
freeSpace: downloadStats.bsize * downloadStats.bavail,
|
||||
totalSpace: downloadStats.bsize * downloadStats.blocks,
|
||||
downloadsType: downloadStats.type
|
||||
};
|
||||
})
|
||||
.get('/notifications', ({ set }) =>
|
||||
|
|
@ -41,13 +52,105 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
set.headers['connection'] = 'keep-alive';
|
||||
return new Response(buildNotificationsStream());
|
||||
})
|
||||
.get('/info/battery', async () =>
|
||||
{
|
||||
return si.battery();
|
||||
})
|
||||
.get('/info/wifi', async () =>
|
||||
{
|
||||
return si.wifiConnections();
|
||||
})
|
||||
.get('/info/bluetooth', async () =>
|
||||
{
|
||||
return si.bluetoothDevices();
|
||||
})
|
||||
.get('/drives', async () =>
|
||||
{
|
||||
const drives = await getDevices();
|
||||
return drives;
|
||||
})
|
||||
.get('/drives/download', async () =>
|
||||
{
|
||||
const drives = await getDevicesCurated();
|
||||
const downloadsPath = config.get('downloadPath');
|
||||
const currentDownloadsSize = await getFolderSize(downloadsPath);
|
||||
let used = false;
|
||||
const drivesDownload: DownloadsDrive[] = drives
|
||||
.filter(d => !!d.mountPoint)
|
||||
.map(d => ({ ...d, depth: d.mountPoint!.split(path.sep).length }))
|
||||
.sort((a, b) => b.depth - a.depth)
|
||||
.map(d =>
|
||||
{
|
||||
const drive: DownloadsDrive = {
|
||||
device: d.device,
|
||||
label: d.label,
|
||||
mountPoint: path.join(d.mountPoint!, 'gameflow'),
|
||||
isRemovable: d.isRemovable,
|
||||
size: d.size,
|
||||
used: d.used,
|
||||
isCurrentlyUsed: false,
|
||||
unusableReason: null
|
||||
};
|
||||
|
||||
if (!used && d.mountPoint && downloadsPath.startsWith(d.mountPoint))
|
||||
{
|
||||
drive.isCurrentlyUsed = true;
|
||||
used = true;
|
||||
}
|
||||
|
||||
if (!drive.isCurrentlyUsed && currentDownloadsSize && drive.size - drive.used <= currentDownloadsSize.size)
|
||||
{
|
||||
drive.unusableReason = 'not_enough_space';
|
||||
}
|
||||
else if (drive.isCurrentlyUsed && downloadsPath === drive.mountPoint)
|
||||
{
|
||||
drive.unusableReason = 'already_used';
|
||||
}
|
||||
|
||||
return drive;
|
||||
});
|
||||
return {
|
||||
downloadsSize: currentDownloadsSize.size,
|
||||
configPath: dirname(config.path),
|
||||
drives: drivesDownload,
|
||||
};
|
||||
})
|
||||
.put('/dirs', async ({ body: { dirname, name } }) =>
|
||||
{
|
||||
await fs.mkdir(path.join(dirname, name));
|
||||
}, {
|
||||
body: z.object({ dirname: z.string(), name: z.string() })
|
||||
})
|
||||
.get('/dirs', async ({ query: { path: startingPath } }) =>
|
||||
{
|
||||
const currentPath = startingPath ?? dirname(Bun.main);
|
||||
const paths = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
return {
|
||||
name: path.basename(currentPath),
|
||||
parentPath: path.dirname(currentPath),
|
||||
dirs: paths.sort((a, b) => (b.isDirectory() ? 1 : 0) - (a.isDirectory() ? 1 : 0)).map(p =>
|
||||
({
|
||||
name: p.name,
|
||||
parentPath: p.parentPath,
|
||||
isDirectory: p.isDirectory()
|
||||
}))
|
||||
};
|
||||
},
|
||||
{
|
||||
query: z.object({ path: z.string().optional() }),
|
||||
response: z.object({
|
||||
name: z.string(),
|
||||
parentPath: z.string(),
|
||||
dirs: z.array(DirSchema)
|
||||
})
|
||||
})
|
||||
.post('/exit', () =>
|
||||
{
|
||||
events.emit('exitapp');
|
||||
})
|
||||
.post('/open', async ({ query: { url } }) =>
|
||||
.post('/open', async ({ body: { url } }) =>
|
||||
{
|
||||
open(url);
|
||||
await openExternal(url);
|
||||
}, {
|
||||
query: z.object({ url: z.url() })
|
||||
body: z.object({ url: z.string() })
|
||||
});
|
||||
|
|
@ -46,6 +46,18 @@ export class TaskQueue
|
|||
return this.activeQueue.length > 0;
|
||||
}
|
||||
|
||||
public hasActiveOfType (type: any)
|
||||
{
|
||||
for (const entry of this.activeQueue)
|
||||
{
|
||||
if (entry.context.job instanceof type)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public waitForJob (id: string): Promise<void>
|
||||
{
|
||||
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue