feat First implementation of plugins system
feat: Added PCSX2 integration feat: Revamped UI a bit made it look better on light mode
This commit is contained in:
parent
d85268fad7
commit
a78e75335f
95 changed files with 2639 additions and 1259 deletions
85
src/bun/api/jobs/bios-download-job.ts
Normal file
85
src/bun/api/jobs/bios-download-job.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import z from "zod";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
||||
import { config } from "../app";
|
||||
import { getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||
import fs from 'node:fs/promises';
|
||||
import { hashFile, simulateProgress } from "@/bun/utils";
|
||||
import { Downloader, FileEntry } from "@/bun/utils/downloader";
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||
|
||||
export class BiosDownloadJob implements IJob<z.infer<typeof BiosDownloadJob.dataSchema>, "download">
|
||||
{
|
||||
static id = "bios-download-job" as const;
|
||||
static dataSchema = z.object({ emulator: z.string() });
|
||||
static query = (q: { id: string; }) => `${BiosDownloadJob.id}-${q.id}`;
|
||||
group: string = "bios-download";
|
||||
emulator: string;
|
||||
dryRun: boolean;
|
||||
|
||||
constructor(emulator: string, init?: { dryRun?: boolean; })
|
||||
{
|
||||
this.emulator = emulator;
|
||||
this.dryRun = init?.dryRun ?? false;
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<never, "download">, never, "download">)
|
||||
{
|
||||
const allRommPlatforms = await getOrCached(CACHE_KEYS.ROM_PLATFORMS, () => getPlatformsApiPlatformsGet({ throwOnError: true }), { expireMs: 60 * 60 * 1000 }).then(d => d.data);
|
||||
|
||||
const emulator = await getStoreEmulatorPackage(this.emulator);
|
||||
if (!emulator) throw new Error("Could Not Find Emulator");
|
||||
|
||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||
|
||||
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
|
||||
await ensureDir(biosFolder);
|
||||
const rommPlatforms = systems.filter(s => s.romm_slug).map(s => allRommPlatforms.find(p => p.slug == s.romm_slug)).filter(r => !!r);
|
||||
|
||||
const firmwaresToDownload: FileEntry[] = [];
|
||||
|
||||
for (const rommPlatform of rommPlatforms)
|
||||
{
|
||||
const firmwares = await getPlatformFirmwareApiFirmwareGet({ query: { platform_id: rommPlatform.id } }).then(d => d.data);
|
||||
if (firmwares)
|
||||
{
|
||||
for (const firmware of firmwares)
|
||||
{
|
||||
const firmwarePath = path.join(biosFolder, firmware.file_name);
|
||||
const exists = await fs.exists(firmwarePath);
|
||||
|
||||
if (exists && await hashFile(firmwarePath, 'sha1'))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
firmwaresToDownload.push({ file_name: firmware.file_name, file_path: '', url: new URL(`http://romm.simeonradivoev.com/api/firmware/${firmware.id}/content/${encodeURIComponent(firmware.file_name)}`) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.dryRun)
|
||||
{
|
||||
await simulateProgress((p) => context.setProgress(p, 'download'), context.abortSignal);
|
||||
} else
|
||||
{
|
||||
const downloader = new Downloader('bios-download', firmwaresToDownload, biosFolder, {
|
||||
signal: context.abortSignal,
|
||||
onProgress (stats)
|
||||
{
|
||||
context.setProgress(stats.progress, "download");
|
||||
},
|
||||
});
|
||||
|
||||
await downloader.start();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
{
|
||||
return { emulator: this.emulator };
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import _7z from '7zip-min';
|
|||
import fs from "node:fs/promises";
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import { move } from "fs-extra";
|
||||
import { simulateProgress } from "@/bun/utils";
|
||||
|
||||
type EmulatorDownloadStates = "download" | "extract";
|
||||
|
||||
|
|
@ -20,11 +21,13 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
emulator: string;
|
||||
downloadSource: string;
|
||||
emulatorPackage?: EmulatorPackageType;
|
||||
dryRun?: boolean;
|
||||
|
||||
constructor(emulator: string, downloadSource: string)
|
||||
constructor(emulator: string, downloadSource: string, init?: { dryRun?: boolean; })
|
||||
{
|
||||
this.emulator = emulator;
|
||||
this.downloadSource = downloadSource;
|
||||
this.dryRun = init?.dryRun ?? false;
|
||||
}
|
||||
|
||||
async start (context: JobContext<EmulatorDownloadJob, z.infer<typeof EmulatorDownloadJob.dataSchema>, EmulatorDownloadStates>)
|
||||
|
|
@ -56,44 +59,53 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
|
|||
throw new Error("Invalid Download Type");
|
||||
}
|
||||
|
||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||
const downloader = new Downloader(this.emulator,
|
||||
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }],
|
||||
tmpFolder,
|
||||
{
|
||||
onProgress (stats)
|
||||
{
|
||||
context.setProgress(stats.progress, 'download');
|
||||
},
|
||||
});
|
||||
|
||||
const destinationPaths = await downloader.start();
|
||||
if (destinationPaths)
|
||||
if (this.dryRun)
|
||||
{
|
||||
if (isArchive)
|
||||
{
|
||||
if (await downloader.start() && destinationPaths[0])
|
||||
await simulateProgress(p => context.setProgress(p, "download"), context.abortSignal);
|
||||
await simulateProgress(p => context.setProgress(p, "extract"), context.abortSignal);
|
||||
} else
|
||||
{
|
||||
const tmpFolder = path.join(config.get("downloadPath"), ".tmp");
|
||||
const downloader = new Downloader(this.emulator,
|
||||
[{ url: new URL(downloadUrl), file_name: path.basename(downloadUrl), file_path: this.emulator }],
|
||||
tmpFolder,
|
||||
{
|
||||
let destinationPath = destinationPaths[0];
|
||||
await _7z.unpack(destinationPath, emulatorsFolder);
|
||||
await fs.rm(destinationPath, { recursive: true });
|
||||
|
||||
// check if 1 root folder we need to get rid of
|
||||
const contents = await fs.readdir(emulatorsFolder);
|
||||
if (contents.length === 1)
|
||||
signal: context.abortSignal,
|
||||
onProgress (stats)
|
||||
{
|
||||
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
|
||||
if (stat.isDirectory())
|
||||
context.setProgress(stats.progress, 'download');
|
||||
},
|
||||
});
|
||||
|
||||
const destinationPaths = await downloader.start();
|
||||
if (destinationPaths)
|
||||
{
|
||||
if (isArchive)
|
||||
{
|
||||
if (await downloader.start() && destinationPaths[0])
|
||||
{
|
||||
let destinationPath = destinationPaths[0];
|
||||
await _7z.unpack(destinationPath, emulatorsFolder);
|
||||
await fs.rm(destinationPath, { recursive: true });
|
||||
|
||||
// check if 1 root folder we need to get rid of
|
||||
const contents = await fs.readdir(emulatorsFolder);
|
||||
if (contents.length === 1)
|
||||
{
|
||||
console.log("Found 1 root folder, using that instead");
|
||||
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
|
||||
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
|
||||
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
|
||||
const stat = await fs.stat(path.join(emulatorsFolder, contents[0]));
|
||||
if (stat.isDirectory())
|
||||
{
|
||||
console.log("Found 1 root folder, using that instead");
|
||||
const tmpEmulatorsFolder = `${emulatorsFolder} (1)`;
|
||||
await move(path.join(emulatorsFolder, contents[0]), tmpEmulatorsFolder, { overwrite: true });
|
||||
await move(tmpEmulatorsFolder, emulatorsFolder, { overwrite: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import * as schema from "@schema/app";
|
|||
import * as emulatorSchema from "@schema/emulators";
|
||||
import path from 'node:path';
|
||||
import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet, PlatformSchema } from "@clients/romm";
|
||||
import { config, db, emulatorsDb, events, jar } from "../app";
|
||||
import { config, db, emulatorsDb, events } from "../app";
|
||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
||||
import * as igdb from 'ts-igdb-client';
|
||||
import secrets from "../secrets";
|
||||
import { hashFile } from "@/bun/utils";
|
||||
import { hashFile, simulateProgress } from "@/bun/utils";
|
||||
import { Downloader } from "@/bun/utils/downloader";
|
||||
import { sleep } from "bun";
|
||||
import _7z from '7zip-min';
|
||||
import z from "zod";
|
||||
|
||||
interface JobConfig
|
||||
{
|
||||
|
|
@ -25,11 +25,14 @@ export type InstallJobStates = 'download' | 'extract';
|
|||
|
||||
export class InstallJob implements IJob<never, InstallJobStates>
|
||||
{
|
||||
static id = "install-job" as const;
|
||||
static query = (q: { source: string; id: string; }) => `${InstallJob.id}-${q.source}-${q.id}`;
|
||||
static dataSchema = z.never();
|
||||
public gameId: string;
|
||||
public source: string;
|
||||
public sourceId: string;
|
||||
public config?: JobConfig;
|
||||
static id = "install-job" as const;
|
||||
|
||||
public group = InstallJob.id;
|
||||
|
||||
constructor(id: string, source: string, sourceId: string, config?: JobConfig)
|
||||
|
|
@ -53,7 +56,6 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
file_name: string;
|
||||
size?: number;
|
||||
}[] = [];
|
||||
let cookie: string = '';
|
||||
let screenshotUrls: string[];
|
||||
let coverUrl: string;
|
||||
let rommPlatform: PlatformSchema | undefined;
|
||||
|
|
@ -115,7 +117,6 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
}));
|
||||
|
||||
files.push(...rommFiles.filter(f => f !== undefined));
|
||||
cookie = await jar.getCookieString(config.get('rommAddress') ?? '');
|
||||
break;
|
||||
case 'store':
|
||||
const game = await getStoreGameFromId(this.gameId);
|
||||
|
|
@ -295,12 +296,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
});
|
||||
} else
|
||||
{
|
||||
for (let i = 0; i < 10; i++)
|
||||
{
|
||||
cx.setProgress(i * 10, "download");
|
||||
if (cx.abortSignal.aborted) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Elysia from "elysia";
|
||||
import z, { _ZodType, ZodAny, ZodObject, ZodTypeAny } from "zod";
|
||||
import z, { _ZodType } from "zod";
|
||||
import { taskQueue } from "../app";
|
||||
import { LoginJob } from "./login-job";
|
||||
import TwitchLoginJob from "./twitch-login-job";
|
||||
|
|
@ -7,22 +7,27 @@ import UpdateStoreJob from "./update-store";
|
|||
import { EmulatorDownloadJob } from "./emulator-download-job";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { IJob } from "../task-queue";
|
||||
import { LaunchGameJob } from "./launch-game-job";
|
||||
import { BiosDownloadJob } from "./bios-download-job";
|
||||
import { InstallJob } from "./install-job";
|
||||
|
||||
function registerJob<
|
||||
const Path extends string,
|
||||
const Schema extends ZodTypeAny,
|
||||
const Schema extends z.ZodTypeAny,
|
||||
const Query extends z.ZodTypeAny,
|
||||
const States extends string,
|
||||
T extends IJob<z.infer<Schema>, States>
|
||||
> (_job: { id: Path; dataSchema: Schema; } & (new (...args: any[]) => T))
|
||||
> (_job: { id: Path; dataSchema: Schema; query?: (q: any) => string; } & (new (...args: any[]) => T))
|
||||
{
|
||||
return new Elysia().ws(_job.id, {
|
||||
body: z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('cancel') })
|
||||
]),
|
||||
query: z.record(z.string(), z.any()),
|
||||
response: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(['data', 'started', 'progress']),
|
||||
status: z.string(),
|
||||
state: z.string().optional(),
|
||||
progress: z.number(),
|
||||
data: _job.dataSchema
|
||||
}),
|
||||
|
|
@ -31,44 +36,45 @@ function registerJob<
|
|||
]),
|
||||
open (ws)
|
||||
{
|
||||
const job = taskQueue.findJob(_job.id, _job);
|
||||
const jobId = (_job.query ? _job.query(ws.data.query) : _job.id);
|
||||
const job = taskQueue.findJob(jobId, _job);
|
||||
if (job)
|
||||
{
|
||||
ws.send({ type: 'data', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
ws.send({ type: 'data', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
|
||||
(ws.data as any).cleanup = [
|
||||
taskQueue.on('started', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
ws.send({ type: 'started', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('progress', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'started', status: job.status, progress: job.progress, data: job.job.exposeData?.() });
|
||||
ws.send({ type: 'progress', state: job.state, progress: job.progress, data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('completed', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'completed', data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('ended', ({ id, job }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'ended', data: job.job.exposeData?.() });
|
||||
}
|
||||
}),
|
||||
taskQueue.on('error', ({ id, error }) =>
|
||||
{
|
||||
if (id === _job.id)
|
||||
if (id === jobId)
|
||||
{
|
||||
ws.send({ type: 'error', error: getErrorMessage(error) });
|
||||
}
|
||||
|
|
@ -83,7 +89,8 @@ function registerJob<
|
|||
{
|
||||
if (message.type === 'cancel')
|
||||
{
|
||||
taskQueue.findJob(_job.id, _job)?.abort('cancel');
|
||||
const jobId = (_job.query ? _job.query(this.query) : _job.id);
|
||||
taskQueue.findJob(jobId, _job)?.abort('cancel');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -93,4 +100,7 @@ export const jobs = new Elysia({ prefix: '/api/jobs' })
|
|||
.use(registerJob(LoginJob))
|
||||
.use(registerJob(TwitchLoginJob))
|
||||
.use(registerJob(UpdateStoreJob))
|
||||
.use(registerJob(LaunchGameJob))
|
||||
.use(registerJob(BiosDownloadJob))
|
||||
.use(registerJob(InstallJob))
|
||||
.use(registerJob(EmulatorDownloadJob));
|
||||
|
|
|
|||
121
src/bun/api/jobs/launch-game-job.ts
Normal file
121
src/bun/api/jobs/launch-game-job.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import z from "zod";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { ActiveGameSchema, ActiveGameType } from "@/bun/types/typesc.schema";
|
||||
import { db, events, plugins } from "../app";
|
||||
import * as appSchema from "@schema/app";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { spawn } from 'node:child_process';
|
||||
import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm';
|
||||
|
||||
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
|
||||
{
|
||||
static id = "launch-game" as const;
|
||||
static dataSchema = z.optional(ActiveGameSchema);
|
||||
group = "launch-game";
|
||||
activeGame?: ActiveGameType;
|
||||
gameId: number;
|
||||
validCommand: CommandEntry;
|
||||
gameSource: string;
|
||||
gameSourceId: string;
|
||||
|
||||
constructor(gameId: number, validCommand: CommandEntry, source: string, sourceId: string)
|
||||
{
|
||||
this.gameId = gameId;
|
||||
this.validCommand = validCommand;
|
||||
this.gameSource = source;
|
||||
this.gameSourceId = sourceId;
|
||||
}
|
||||
|
||||
async start (context: JobContext<IJob<ActiveGameType, "playing">, ActiveGameType, "playing">)
|
||||
{
|
||||
const localGame = await db.query.games.findFirst({
|
||||
where: eq(appSchema.games.id, this.gameId), columns: {
|
||||
name: true,
|
||||
source_id: true,
|
||||
source: true
|
||||
}
|
||||
});
|
||||
|
||||
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
|
||||
autoValidCommand: this.validCommand,
|
||||
game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId }
|
||||
});
|
||||
const command = commandArgs ? this.validCommand.metadata.emulatorBin ?? this.validCommand.command : this.validCommand.command;
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
const game = spawn(command, commandArgs, {
|
||||
shell: true,
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal
|
||||
});
|
||||
|
||||
game.stdout.on('data', data => console.log(data));
|
||||
game.on('close', (code) =>
|
||||
{
|
||||
resolve(code);
|
||||
});
|
||||
game.on('error', e =>
|
||||
{
|
||||
console.error(e);
|
||||
reject(e);
|
||||
});
|
||||
|
||||
this.activeGame = {
|
||||
process: game,
|
||||
name: localGame?.name ?? "Unknown",
|
||||
gameId: this.gameId,
|
||||
command: this.validCommand
|
||||
};
|
||||
|
||||
function updateRommProps (id: number)
|
||||
{
|
||||
updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } });
|
||||
events.emit('notification', { message: "Updated Last Played", type: 'success' });
|
||||
}
|
||||
|
||||
if (this.gameSource === 'romm')
|
||||
{
|
||||
updateRommProps(Number(this.gameSourceId));
|
||||
}
|
||||
else if (localGame?.source === 'romm' && localGame.source_id)
|
||||
{
|
||||
updateRommProps(Number(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');
|
||||
}*/
|
||||
}
|
||||
|
||||
exposeData ()
|
||||
{
|
||||
return this.activeGame;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import Elysia, { status } from "elysia";
|
||||
import { IJob, JobBase, JobContext, JobContextFromClass } from "../task-queue";
|
||||
import { IJob, JobContext } from "../task-queue";
|
||||
import { LOGIN_PORT, SERVER_URL } from "@/shared/constants";
|
||||
import { host, localIp } from "@/bun/utils/host";
|
||||
import cors from "@elysiajs/cors";
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { getStoreRootFolder } from "../store/services/gamesService";
|
|||
import { STORE_VERSION } from "@/shared/constants";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import z from "zod";
|
||||
|
||||
export default class UpdateStoreJob implements IJob<never, never>
|
||||
{
|
||||
static id = "update-store" as const;
|
||||
static dataSchema = z.never();
|
||||
packageName: string;
|
||||
registry: URL;
|
||||
storeVersion: string;
|
||||
|
|
@ -27,7 +29,8 @@ export default class UpdateStoreJob implements IJob<never, never>
|
|||
const storeFolder = getStoreRootFolder();
|
||||
await ensureDir(storeFolder);
|
||||
|
||||
await Bun.spawn([process.execPath, "install", `${this.packageName}@${this.storeVersion}`, "--registry", this.registry.href], {
|
||||
console.log("Updating Store");
|
||||
const proc = Bun.spawn([process.execPath, "add", `${this.packageName}@${this.storeVersion}`, "--production", "--registry", this.registry.href], {
|
||||
cwd: storeFolder,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
|
|
@ -35,6 +38,13 @@ export default class UpdateStoreJob implements IJob<never, never>
|
|||
BUN_BE_BUN: "1",
|
||||
BUN_INSTALL_CACHE_DIR: tempCache
|
||||
}
|
||||
}).exited;
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
console.log(stdout);
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
if (stderr)
|
||||
console.error(stderr);
|
||||
await proc.exited;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue