feat: Moved to stream zip downloading.
feat: Implemented Shortcuts. feat: Ensured it works on steam deck
This commit is contained in:
parent
f15bf9a1e0
commit
62f16cbcc1
45 changed files with 1415 additions and 631 deletions
|
|
@ -8,7 +8,7 @@ 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 { Notification, 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";
|
||||
|
|
@ -18,6 +18,7 @@ import os from 'node:os';
|
|||
import { ActiveGame } from "../types/types";
|
||||
import EventEmitter from "node:events";
|
||||
import { ErrorLike } from "bun";
|
||||
import { getErrorMessage } from "../utils";
|
||||
|
||||
export const config = new Conf<SettingsType>({
|
||||
projectName: projectPackage.name,
|
||||
|
|
@ -58,7 +59,14 @@ export function setActiveGame (game: ActiveGame)
|
|||
return activeGame = game;
|
||||
}
|
||||
export const events = new EventEmitter<AppEventMap>();
|
||||
events.addListener('activegameexit', () => activeGame = undefined);
|
||||
events.addListener('activegameexit', ({ error }) =>
|
||||
{
|
||||
activeGame = undefined;
|
||||
if (error)
|
||||
{
|
||||
events.emit('notification', { message: getErrorMessage(error), type: 'error' });
|
||||
}
|
||||
});
|
||||
console.log("Logging In to Romm");
|
||||
|
||||
export async function cleanup ()
|
||||
|
|
@ -71,6 +79,7 @@ export async function cleanup ()
|
|||
|
||||
interface AppEventMap
|
||||
{
|
||||
activegameexit: [{ subprocess: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
activegameexit: [{ subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }];
|
||||
exitapp: [];
|
||||
notification: [Notification];
|
||||
}
|
||||
|
|
@ -5,12 +5,14 @@ import z from "zod";
|
|||
import * as schema from "../schema/app";
|
||||
import fs from "node:fs/promises";
|
||||
import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants";
|
||||
import { getRomApiRomsIdGet, getRomsApiRomsGet } from "@clients/romm";
|
||||
import { getRomApiRomsIdGet, getRomsApiRomsGet, updateRomUserApiRomsIdPropsPut } from "@clients/romm";
|
||||
import { InstallJob } from "../jobs/install-job";
|
||||
import path from "node:path";
|
||||
import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils";
|
||||
import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService";
|
||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export default new Elysia()
|
||||
.get('/game/local/:id/cover', async ({ params: { id }, set }) =>
|
||||
|
|
@ -215,29 +217,89 @@ export default new Elysia()
|
|||
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: eq(schema.games.id, validCommand.gameId), columns: {
|
||||
name: true
|
||||
|
||||
name: true,
|
||||
source_id: true,
|
||||
source: true
|
||||
}
|
||||
});
|
||||
|
||||
const game = setActiveGame({
|
||||
process: Bun.spawn({
|
||||
cmd: validCommand.command.command.split(' '), onExit (subprocess, exitCode, signalCode, error)
|
||||
{
|
||||
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
||||
},
|
||||
}),
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: validCommand.gameId,
|
||||
command: validCommand.command.command
|
||||
});
|
||||
|
||||
await game.process.exited;
|
||||
if (game.process.exitCode && game.process.exitCode > 0)
|
||||
try
|
||||
{
|
||||
return status('Internal Server Error');
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
const game = spawn(validCommand.command.command, {
|
||||
shell: true
|
||||
});
|
||||
game.stdout.on('data', data => console.log(data));
|
||||
game.on('close', (code) =>
|
||||
{
|
||||
events.emit('activegameexit', { exitCode: code, signalCode: null });
|
||||
resolve(code);
|
||||
});
|
||||
game.on('error', e =>
|
||||
{
|
||||
events.emit('activegameexit', { exitCode: null, signalCode: null, error: e });
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
setActiveGame({
|
||||
pid: game.pid,
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: validCommand.gameId,
|
||||
command: validCommand.command.command
|
||||
});
|
||||
|
||||
function updateRommProps (id: number)
|
||||
{
|
||||
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
|
||||
events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||
}
|
||||
|
||||
if (source === 'romm')
|
||||
{
|
||||
updateRommProps(id);
|
||||
}
|
||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||
{
|
||||
updateRommProps(localGame.source_id);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/*
|
||||
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
|
||||
const game = setActiveGame({
|
||||
process: Bun.spawn({
|
||||
cmd,
|
||||
env: {
|
||||
...process.env
|
||||
},
|
||||
onExit (subprocess, exitCode, signalCode, error)
|
||||
{
|
||||
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}),
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: validCommand.gameId,
|
||||
command: validCommand.command.command
|
||||
});
|
||||
|
||||
await game.process.exited;
|
||||
if (game.process.exitCode && game.process.exitCode > 0)
|
||||
{
|
||||
return status('Internal Server Error');
|
||||
}*/
|
||||
return status('OK');
|
||||
|
||||
} catch (error)
|
||||
{
|
||||
return status('Internal Server Error', getErrorMessage(error));
|
||||
}
|
||||
return status('OK');
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import path, { basename, dirname } from 'node:path';
|
||||
import path from 'node:path';
|
||||
import { which } from 'bun';
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import * as schema from '../../schema/emulators';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config, emulatorsDb } from '../../app';
|
||||
import os from 'node:os';
|
||||
import { $ } from 'bun';
|
||||
|
||||
export const varRegex = /%([^%]+)%/g;
|
||||
|
||||
|
|
@ -78,40 +79,79 @@ export async function getValidLaunchCommands (data: {
|
|||
const formattedCommands = await Promise.all(system.commands.map(async command =>
|
||||
{
|
||||
const label = command.label;
|
||||
const cmd = command.command;
|
||||
let cmd = command.command;
|
||||
|
||||
const matches = cmd.match(varRegex);
|
||||
if (matches)
|
||||
let emulator: string | undefined = undefined;
|
||||
let rom = validFiles[0];
|
||||
|
||||
if (cmd.includes('%ESCAPESPECIALS%'))
|
||||
rom = rom.replace(/[&()^=;,]/g, '');
|
||||
|
||||
const staticVars: Record<string, string> = {
|
||||
'%ROM%': $.escape(rom),
|
||||
'%ROMRAW%': validFiles[0],
|
||||
'%ROMRAWWIN%': validFiles[0].replace('/', '\\'),
|
||||
'%ESPATH%': path.dirname(Bun.main),
|
||||
'%ROMPATH%': $.escape(gamePath),
|
||||
'%BASENAME%': path.basename(validFiles[0], path.extname(validFiles[0])),
|
||||
'%FILENAME%': path.basename(validFiles[0])
|
||||
};
|
||||
|
||||
cmd = cmd.replace(/\%INJECT\%=(?<inject>[\w\%.\/\\]+)/g, (subscring, injectFile: string) =>
|
||||
{
|
||||
let emulator: string | undefined = undefined;
|
||||
const varList = await Promise.all(matches.map(async (value) =>
|
||||
try
|
||||
{
|
||||
if (value.startsWith("%EMULATOR_"))
|
||||
const resolvedInjectFile = injectFile.replace(varRegex, (a) =>
|
||||
{
|
||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||
let exec = await findExec(emulatorName);
|
||||
if (data.customEmulatorConfig.has(emulatorName))
|
||||
{
|
||||
exec = data.customEmulatorConfig.get(emulatorName);
|
||||
}
|
||||
|
||||
emulator = emulatorName;
|
||||
return [value, exec];
|
||||
return staticVars[a] ?? a;
|
||||
});
|
||||
if (existsSync(resolvedInjectFile))
|
||||
{
|
||||
const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' });
|
||||
return rawContents.split('\n').map(v => v.replace('\r', '')).join(' ');
|
||||
}
|
||||
|
||||
const key = value.substring(1, value.length - 1);
|
||||
return [value, process.env[key]];
|
||||
}));
|
||||
const vars = Object.fromEntries(varList);
|
||||
vars['%ROM%'] = validFiles[0];
|
||||
vars['%ESPATH%'] = config.get('downloadPath');
|
||||
return '';
|
||||
} catch (error)
|
||||
{
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
// missing variable
|
||||
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
||||
const matches = Array.from(cmd.matchAll(varRegex));
|
||||
const varList = await Promise.all(matches.map(async ([value]) =>
|
||||
{
|
||||
if (value.startsWith("%EMULATOR_"))
|
||||
{
|
||||
const emulatorName = value.substring("%EMULATOR_".length, value.length - 1);
|
||||
let exec = await findExec(emulatorName);
|
||||
if (data.customEmulatorConfig.has(emulatorName))
|
||||
{
|
||||
exec = data.customEmulatorConfig.get(emulatorName);
|
||||
}
|
||||
|
||||
const command = cmd.replace(varRegex, (s) => vars[s] ?? '');
|
||||
return { label: label ?? undefined, command, valid: !invalid, emulator } satisfies CommandEntry;
|
||||
}
|
||||
emulator = emulatorName;
|
||||
return [[value, exec ? exec : undefined], ['%EMUDIR%', exec ? $.escape(path.dirname(exec)) : undefined]];
|
||||
}
|
||||
|
||||
const key = value[0].substring(1, value.length - 1);
|
||||
return [[value, process.env[key]]];
|
||||
}));
|
||||
|
||||
const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars };
|
||||
vars['%ESCAPESPECIALS%'] = "";
|
||||
vars['%HIDEWINDOW%'] = '';
|
||||
|
||||
// missing variable
|
||||
const invalid = Object.entries(vars).find(c => c[1] === undefined);
|
||||
|
||||
const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim();
|
||||
|
||||
return {
|
||||
label: label ?? undefined,
|
||||
command: formattedCommand,
|
||||
valid: !invalid, emulator
|
||||
} satisfies CommandEntry;
|
||||
}));
|
||||
|
||||
return formattedCommands.filter(c => !!c);
|
||||
|
|
@ -165,8 +205,8 @@ export async function findExec (emulatorName: string)
|
|||
async function readRegistryValue (text: string)
|
||||
{
|
||||
const params = text.split('|');
|
||||
const key = dirname(params[0]);
|
||||
const value = basename(params[0]);
|
||||
const key = path.dirname(params[0]);
|
||||
const value = path.basename(params[0]);
|
||||
const bin = params.length > 1 ? params[1] : undefined;
|
||||
|
||||
const proc = Bun.spawn({
|
||||
|
|
@ -197,9 +237,10 @@ async function resolveStaticPath (entries: string[])
|
|||
{
|
||||
for (const entry of entries)
|
||||
{
|
||||
for await (const match of fs.glob(entry))
|
||||
const resolved = entry.replace("~", os.homedir());
|
||||
if (await fs.exists(resolved))
|
||||
{
|
||||
return match;
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { GameInstallProgress, GameStatusType, } from "@shared/constants";
|
||||
import { activeGame, customEmulators, db, events, taskQueue } from "../../app";
|
||||
import { activeGame, config, customEmulators, db, events, taskQueue } from "../../app";
|
||||
import { getValidLaunchCommands } from "./launchGameService";
|
||||
import * as schema from '../../schema/app';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { getLocalGameMatch } from "./utils";
|
||||
import { getRomApiRomsIdGet } from "@/clients/romm";
|
||||
import fs from 'node:fs/promises';
|
||||
import { ErrorLike } from "elysia/universal";
|
||||
|
||||
class CommandSearchError extends Error
|
||||
{
|
||||
|
|
@ -116,9 +119,19 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
enqueue({ status: 'installed', details: validCommand.command.label });
|
||||
}
|
||||
|
||||
} else
|
||||
} else if (source === 'romm')
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
// TODO: Add Caching
|
||||
const remoteGame = await getRomApiRomsIdGet({ path: { id } });
|
||||
const stats = await fs.statfs(config.get('downloadPath'));
|
||||
if (remoteGame.data?.fs_size_bytes && remoteGame.data?.fs_size_bytes > stats.bsize * stats.bavail)
|
||||
{
|
||||
enqueue({ status: 'error', error: "Not Enough Free Space" });
|
||||
} else
|
||||
{
|
||||
enqueue({ status: 'install', details: 'Install' });
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,8 +139,15 @@ export default async function buildStatusResponse (source: string, id: number)
|
|||
await sendLatests();
|
||||
|
||||
const dispose: Function[] = [];
|
||||
const handleActiveExit = async () =>
|
||||
const handleActiveExit = async (data: { error?: ErrorLike; }) =>
|
||||
{
|
||||
if (data.error)
|
||||
{
|
||||
enqueue({
|
||||
status: 'error',
|
||||
error: data.error
|
||||
}, 'error');
|
||||
}
|
||||
await sendLatests();
|
||||
};
|
||||
events.on('activegameexit', handleActiveExit);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { IJob, JobContext } from "../task-queue";
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
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 { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm";
|
||||
import { downloadRomsApiRomsDownloadGet, 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
|
||||
{
|
||||
|
|
@ -39,6 +42,7 @@ export class InstallJob implements IJob
|
|||
|
||||
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));
|
||||
|
|
@ -84,7 +88,38 @@ export class InstallJob implements IJob
|
|||
await zip.extract(null, downloadPath);
|
||||
await zip.close();
|
||||
|
||||
await fs.rm(zipFilePath);
|
||||
await fs.rm(zipFilePath);*/
|
||||
|
||||
cx.setProgress(0, 'download');
|
||||
const downloadUrl = new URL(`${config.get('rommAddress')}/api/roms/download`);
|
||||
downloadUrl.searchParams.set('rom_ids', String(this.id));
|
||||
const res = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
cookie: await jar.getCookieString(config.get('rommAddress') ?? '')
|
||||
},
|
||||
});
|
||||
|
||||
const totalBytes = Number(res.headers.get("content-length")) || 0;
|
||||
let bytesReceived = 0;
|
||||
|
||||
const progressStream = new Transform({
|
||||
transform (chunk, encoding, callback)
|
||||
{
|
||||
bytesReceived += chunk.length;
|
||||
if (totalBytes > 0)
|
||||
{
|
||||
const percent = (bytesReceived / totalBytes) * 100;
|
||||
cx.setProgress(percent, 'download');
|
||||
}
|
||||
this.push(chunk);
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
Readable.fromWeb(res.body as any).pipe(progressStream).pipe(unzip.Extract({ path: downloadPath })).on('close', resolve).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const rom = (await getRomApiRomsIdGet({ path: { id: this.id }, throwOnError: true })).data;
|
||||
|
|
@ -115,10 +150,9 @@ export class InstallJob implements IJob
|
|||
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));
|
||||
.select({ slug: emulatorSchema.systemMappings.system, romm_slug: emulatorSchema.systemMappings.sourceSlug })
|
||||
.from(emulatorSchema.systemMappings)
|
||||
.where(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, romPlatform.slug)));
|
||||
|
||||
const existingPlatform = await tx.query.platforms.findFirst({ where: or(...platformSearch) });
|
||||
let platformId: number;
|
||||
|
|
|
|||
28
src/bun/api/notifications.ts
Normal file
28
src/bun/api/notifications.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Notification } from '@shared/constants';
|
||||
import { events } from './app';
|
||||
|
||||
export default function buildNotificationsStream ()
|
||||
{
|
||||
let cleanup: (() => void) | undefined = undefined;
|
||||
return new ReadableStream({
|
||||
async start (controller)
|
||||
{
|
||||
function enqueue (data: Notification, event?: 'notification')
|
||||
{
|
||||
const evntString = event ? `event: ${event}\n` : '';
|
||||
controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
const notificationHandler = (notification: Notification) =>
|
||||
{
|
||||
enqueue(notification, 'notification');
|
||||
};
|
||||
events.on('notification', notificationHandler);
|
||||
cleanup = () => events.removeListener('notification', notificationHandler);
|
||||
},
|
||||
cancel: () =>
|
||||
{
|
||||
cleanup?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2,8 +2,10 @@ import Elysia from "elysia";
|
|||
import open from 'open';
|
||||
import z from "zod";
|
||||
import os from 'node:os';
|
||||
import { events } from "./app";
|
||||
import { config, events } from "./app";
|
||||
import { isSteamDeckGameMode } from "../utils";
|
||||
import fs from 'node:fs/promises';
|
||||
import buildNotificationsStream from "./notifications";
|
||||
|
||||
// steam://open/keyboard?XPosition=%i&YPosition=%i&Width=%i&Height=%i&Mode=%d
|
||||
export const system = new Elysia({ prefix: '/api/system' })
|
||||
|
|
@ -14,8 +16,11 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
open('steam://open/keyboard');
|
||||
}
|
||||
})
|
||||
.get('/info', () =>
|
||||
.get('/info', async () =>
|
||||
{
|
||||
|
||||
const downloadStats = await fs.statfs(config.get('downloadPath'));
|
||||
|
||||
return {
|
||||
homeDir: os.homedir(),
|
||||
user: os.userInfo().username,
|
||||
|
|
@ -23,16 +28,21 @@ export const system = new Elysia({ prefix: '/api/system' })
|
|||
platform: os.platform(),
|
||||
hostname: os.hostname(),
|
||||
steamDeck: process.env.SteamDeck,
|
||||
machine: os.machine()
|
||||
machine: os.machine(),
|
||||
freeSpace: downloadStats.bsize * downloadStats.bavail,
|
||||
totalSpace: downloadStats.bsize * downloadStats.blocks,
|
||||
downloadsType: downloadStats.type
|
||||
};
|
||||
})
|
||||
.get('/notifications', ({ set }) =>
|
||||
{
|
||||
set.headers["content-type"] = 'text/event-stream';
|
||||
set.headers["cache-control"] = 'no-cache';
|
||||
set.headers['connection'] = 'keep-alive';
|
||||
return new Response(buildNotificationsStream());
|
||||
})
|
||||
.post('/exit', () =>
|
||||
{
|
||||
if (process.env.PUBLIC_ACCESS)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
events.emit('exitapp');
|
||||
})
|
||||
.post('/open', async ({ query: { url } }) =>
|
||||
|
|
|
|||
|
|
@ -48,12 +48,14 @@ export class TaskQueue
|
|||
|
||||
public waitForJob (id: string): Promise<void>
|
||||
{
|
||||
return this.queue?.find(j => j.context.id === id)?.promise ?? Promise.resolve();
|
||||
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
||||
return job?.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
public findJob (id: string): IPublicJob | undefined
|
||||
{
|
||||
return this.queue?.find(j => j.context.id === id)?.context;
|
||||
const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id);
|
||||
return job?.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
|
||||
|
|
|
|||
100
src/bun/browser.ts
Normal file
100
src/bun/browser.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { killBrowser, spawnBrowser } from './utils/browser-spawner';
|
||||
import { BuildParams } from './utils/browser-params';
|
||||
import os from 'node:os';
|
||||
import { EventEmitter } from 'node:stream';
|
||||
|
||||
export default async function init (events: EventEmitter, forceBrowser: boolean)
|
||||
{
|
||||
if (forceBrowser)
|
||||
{
|
||||
await runBrowser(events);
|
||||
} else
|
||||
{
|
||||
try
|
||||
{
|
||||
await runWebview(events);
|
||||
} catch (error)
|
||||
{
|
||||
await runBrowser(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runWebview (events: EventEmitter)
|
||||
{
|
||||
const webviewWorker = new Worker(Bun.env.IS_BINARY ? `./webview/${os.platform()}.ts` : new URL(`./webview/${os.platform()}`, import.meta.url).href, {
|
||||
smol: true,
|
||||
});
|
||||
|
||||
return 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runBrowser (events: EventEmitter)
|
||||
{
|
||||
const browserParams = await BuildParams();
|
||||
if (!browserParams)
|
||||
{
|
||||
console.error("Could not find valid browser");
|
||||
return Promise.resolve();
|
||||
}
|
||||
else if (!Bun.env.HEADLESS)
|
||||
{
|
||||
return new Promise((resolve) =>
|
||||
{
|
||||
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: () => resolve(true)
|
||||
}).then(browser =>
|
||||
{
|
||||
events.on('exitapp', () =>
|
||||
{
|
||||
killBrowser(browser);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
}).catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
resolve(e);
|
||||
});
|
||||
});
|
||||
} else
|
||||
{
|
||||
return new Promise(resolve =>
|
||||
{
|
||||
events.on('exitapp', () =>
|
||||
{
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
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';
|
||||
import init from './browser';
|
||||
|
||||
const api = RunAPIServer();
|
||||
let bunServer: { stop: () => void; url: URL; } | undefined;
|
||||
|
|
@ -15,6 +13,7 @@ if (!Bun.env.PUBLIC_ACCESS)
|
|||
|
||||
async function cleanup ()
|
||||
{
|
||||
console.log("Cleaning Up");
|
||||
await appCleanup();
|
||||
bunServer?.stop();
|
||||
await api.apiServer.stop();
|
||||
|
|
@ -22,73 +21,19 @@ async function cleanup ()
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
if (Bun.env.FORCE_BROWSER)
|
||||
if (Bun.env.HEADLESS)
|
||||
{
|
||||
await runBrowser();
|
||||
events.on('exitapp', () =>
|
||||
{
|
||||
process.send?.({ type: 'exitapp' });
|
||||
cleanup();
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
|
||||
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 init(events, !!Bun.env.FORCE_BROWSER);
|
||||
await cleanup();
|
||||
}
|
||||
|
||||
async function runBrowser ()
|
||||
{
|
||||
const browserParams = await BuildParams();
|
||||
if (!browserParams)
|
||||
{
|
||||
console.error("Could not find valid browser");
|
||||
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
|
||||
});
|
||||
|
||||
events.on('exitapp', () => browser.kill(15));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
2
src/bun/types/types.d.ts
vendored
2
src/bun/types/types.d.ts
vendored
|
|
@ -1,7 +1,7 @@
|
|||
declare const IS_BINARY: string;
|
||||
|
||||
export type ActiveGame = {
|
||||
process: Bun.Subprocess;
|
||||
pid?: number;
|
||||
gameId: number;
|
||||
name: string;
|
||||
command: string;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ export async function BuildParams ()
|
|||
args.push('--disabled-features=WindowControlsOverlay,navigationControls,Translate,msUndersideButton');
|
||||
args.push(`--profile-directory=Default`);
|
||||
|
||||
if (Bun.env.NODE_ENV !== 'production')
|
||||
{
|
||||
args.push('--auto-open-devtools-for-tabs');
|
||||
args.push('--remote-debugging-port=9222');
|
||||
}
|
||||
|
||||
if (config.has('windowPosition'))
|
||||
{
|
||||
args.push(`--window-position=${config.get('windowPosition.x')},${config.get('windowPosition.y')}`);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { type Subprocess } from "bun";
|
||||
import { $, type Subprocess } from "bun";
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
export type RunBrowserType = "chrome" | "chromium" | "firefox" | "edge";
|
||||
export type RunBrowserSource = "running" | "system" | "flatpak";
|
||||
|
|
@ -25,6 +29,11 @@ interface SpawnBrowserOptions
|
|||
ipc?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface SpawnLastInfo
|
||||
{
|
||||
PID: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a browser process with proper handling for different installation types.
|
||||
*
|
||||
|
|
@ -52,7 +61,7 @@ interface SpawnBrowserOptions
|
|||
* });
|
||||
* }
|
||||
*/
|
||||
export function spawnBrowser ({
|
||||
export async function spawnBrowser ({
|
||||
browser,
|
||||
args = [],
|
||||
env = {},
|
||||
|
|
@ -61,9 +70,8 @@ export function spawnBrowser ({
|
|||
source,
|
||||
onExit,
|
||||
ipc
|
||||
}: SpawnBrowserOptions): Subprocess
|
||||
}: SpawnBrowserOptions): Promise<Subprocess>
|
||||
{
|
||||
|
||||
// Configuration for both Flatpak and Native
|
||||
// Contains Flatpak app IDs, internal container paths, and fallback binary names
|
||||
const config: Record<RunBrowserType, { id: string; internalCmd: string; bin: string[]; }> = {
|
||||
|
|
@ -91,7 +99,7 @@ export function spawnBrowser ({
|
|||
|
||||
const target = config[browser];
|
||||
const useFlatpak = source === "flatpak";
|
||||
|
||||
|
||||
let cmd: string[];
|
||||
let finalEnv: Record<string, string> | undefined;
|
||||
|
||||
|
|
@ -100,9 +108,9 @@ export function spawnBrowser ({
|
|||
// --- Flatpak Mode (Steam Style) ---
|
||||
// Structure: flatpak run [ENV] [FLATPAK_OPTS] [APP_ID] @@u @@ [USER_ARGS]
|
||||
// The @@u @@ syntax enables file forwarding for URL arguments
|
||||
|
||||
|
||||
const envFlags = Object.entries(env).map(([k, v]) => `--env=${k}=${v}`);
|
||||
|
||||
|
||||
// We explicitly set the command to ensure we don't rely on the default entrypoint failing
|
||||
const flatpakOpts = [
|
||||
"run",
|
||||
|
|
@ -136,11 +144,14 @@ export function spawnBrowser ({
|
|||
console.log(`[Browser] Launching Native: ${execPath}`);
|
||||
}
|
||||
|
||||
const { signal } = new AbortController();
|
||||
const processSub = Bun.spawn(cmd, {
|
||||
env: finalEnv,
|
||||
stdin: "ignore",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
detached,
|
||||
signal,
|
||||
ipc,
|
||||
onExit (_proc, exitCode)
|
||||
{
|
||||
|
|
@ -157,6 +168,17 @@ export function spawnBrowser ({
|
|||
return processSub;
|
||||
}
|
||||
|
||||
export async function killBrowser (browser: Subprocess)
|
||||
{
|
||||
if (os.platform() === 'linux')
|
||||
{
|
||||
// kill chrome by your unique identifier
|
||||
await $`pkill -KILL -P ${browser.pid}`.quiet().nothrow();
|
||||
} else
|
||||
{
|
||||
browser?.kill(15);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test Run ---
|
||||
// spawnBrowser({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue