fix: Fixed a bunch of issues on linux
fix: Removed archive when unzipping with stream zip fallback
This commit is contained in:
parent
7065e64722
commit
6aacec2c0d
22 changed files with 236 additions and 83 deletions
|
|
@ -451,7 +451,7 @@ export default new Elysia()
|
|||
}, {
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
})
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source }, query: { downloadId } }) =>
|
||||
.post('/game/:source/:id/install', async ({ params: { id, source }, body: { downloadId } }) =>
|
||||
{
|
||||
if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob))
|
||||
{
|
||||
|
|
@ -462,7 +462,7 @@ export default new Elysia()
|
|||
}
|
||||
}, {
|
||||
params: z.object({ id: z.string(), source: z.string() }),
|
||||
query: z.object({ downloadId: z.string().optional() }),
|
||||
body: z.object({ downloadId: z.string().optional() }),
|
||||
response: z.any()
|
||||
})
|
||||
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { getErrorMessage } from "@/bun/utils";
|
|||
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
|
||||
import fs from 'node:fs/promises';
|
||||
import Elysia from "elysia";
|
||||
import z from "zod";
|
||||
import z, { string } from "zod";
|
||||
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
||||
import * as appSchema from "@schema/app";
|
||||
import { RPC_URL } from "@/shared/constants";
|
||||
import { DownloadSourceSchema, RPC_URL } from "@/shared/constants";
|
||||
import { host } from "@/bun/utils/host";
|
||||
|
||||
export class CommandSearchError extends Error
|
||||
|
|
@ -205,7 +205,7 @@ export default function buildStatusResponse ()
|
|||
z.object({ status: z.literal('refresh'), localId: z.number().optional() }),
|
||||
z.object({ status: z.literal(['queued']) }),
|
||||
z.object({ status: z.literal('playing'), details: z.string() }),
|
||||
z.object({ status: z.literal('install'), details: z.string() }),
|
||||
z.object({ status: z.literal('install'), details: z.string(), sources: DownloadSourceSchema.array() }),
|
||||
z.object({ status: z.literal('present'), details: z.string() }),
|
||||
z.object({ status: z.literal(['download', 'extract']), progress: z.number() }),
|
||||
]),
|
||||
|
|
@ -261,6 +261,8 @@ export default function buildStatusResponse ()
|
|||
|
||||
} else if (!localGame && ws.data.params.source === 'store')
|
||||
{
|
||||
const downloads = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, id: ws.data.params.id });
|
||||
const sources = downloads?.map(d => ({ id: d.id, name: d.id })) ?? [];
|
||||
/*const storeGame = await getStoreGame(ws.data.params.id);
|
||||
const fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||
const size = Number(fileResponse.headers.get('content-length'));
|
||||
|
|
@ -274,19 +276,20 @@ export default function buildStatusResponse ()
|
|||
ws.send({ status: 'install', details: 'Install' });
|
||||
}*/
|
||||
|
||||
ws.send({ status: 'install', details: 'Install' });
|
||||
ws.send({ status: 'install', details: 'Install', sources });
|
||||
} else if (!localGame)
|
||||
{
|
||||
const files = await plugins.hooks.games.fetchDownloads.promise({
|
||||
source: ws.data.params.source,
|
||||
id: ws.data.params.id
|
||||
});
|
||||
const sources = files?.map(d => ({ id: d.id, name: d.id })) ?? [];
|
||||
|
||||
let filesChecked: LocalDownloadFileEntry[] | undefined;
|
||||
|
||||
if (files)
|
||||
if (files && files.length)
|
||||
{
|
||||
filesChecked = await checkFiles(files.files, !!files.extract_path);
|
||||
filesChecked = await checkFiles(files[0].files, !!files[0].extract_path);
|
||||
}
|
||||
|
||||
if (filesChecked && !filesChecked.some(f => f.exists === false || f.matches === false))
|
||||
|
|
@ -301,11 +304,11 @@ export default function buildStatusResponse ()
|
|||
ws.send({ status: 'error', error: "Not Enough Free Space" });
|
||||
} else if (filesChecked?.some(f => f.exists === true && f.matches === false))
|
||||
{
|
||||
ws.send({ status: 'install', details: 'Some Files Present, Install' });
|
||||
ws.send({ status: 'install', details: 'Some Files Present, Install', sources });
|
||||
}
|
||||
else
|
||||
{
|
||||
ws.send({ status: 'install', details: 'Install' });
|
||||
ws.send({ status: 'install', details: 'Install', sources });
|
||||
}
|
||||
}
|
||||
} else
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export class GameHooks
|
|||
source: string;
|
||||
id: string;
|
||||
downloadId?: string;
|
||||
}], DownloadInfo | undefined>(['ctx']);
|
||||
}], DownloadInfo[] | undefined>(['ctx']);
|
||||
fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
|
||||
source: string;
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
const downloadPath = config.get('downloadPath');
|
||||
let info: DownloadInfo | undefined;
|
||||
|
||||
info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
|
||||
const allDownloads = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
|
||||
info = allDownloads?.[0];
|
||||
|
||||
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
|
||||
|
||||
|
|
@ -137,12 +138,21 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
|||
{
|
||||
if (filePath.endsWith('.zip'))
|
||||
{
|
||||
cx.setProgress(0, "extract");
|
||||
console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
|
||||
await ensureDir(extractPath);
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
let entryCount = await zip.entriesCount;
|
||||
let entryCounter = entryCount;
|
||||
zip.on('extract', (entry, outPath) =>
|
||||
{
|
||||
entryCounter--;
|
||||
cx.setProgress(progress + (1 - (entryCounter / entryCount)) * 100 * progressDelta, "extract");
|
||||
});
|
||||
const count = await zip.extract(null, extractPath);
|
||||
console.log(`Extracted ${count} entries`);
|
||||
await zip.close();
|
||||
await fs.rm(filePath);
|
||||
} else
|
||||
{
|
||||
throw e;
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
|
||||
// ES-DE commands require shell execution. Some emulators fail otherwise.
|
||||
const spawnGame = spawn(this.validCommand.command, {
|
||||
shell: true,
|
||||
shell: this.validCommand.shell ?? true,
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
env: {
|
||||
|
|
@ -151,6 +151,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
|||
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
|
||||
|
||||
// We have full control over launching integrated emulators better to use bun spawn
|
||||
await fs.chmod(this.validCommand.metadata.emulatorBin, 0o755);
|
||||
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], {
|
||||
cwd: this.validCommand.startDir,
|
||||
signal: context.abortSignal,
|
||||
|
|
|
|||
|
|
@ -3,16 +3,13 @@ import desc from './package.json';
|
|||
import { config, events } from "@/bun/api/app";
|
||||
import path, { dirname } from 'node:path';
|
||||
import unzip from 'unzip-stream';
|
||||
import { ensureDir } from "fs-extra";
|
||||
import { chmodSync, ensureDir } from "fs-extra";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import fs from 'node:fs/promises';
|
||||
import { randomUUIDv7, sleep } from "bun";
|
||||
import z from "zod";
|
||||
import { createInterface } from "node:readline";
|
||||
import { redirect } from "elysia";
|
||||
import { getErrorMessage } from "@/bun/utils";
|
||||
import { id } from "zod/v4/locales";
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
runWebGui: z.boolean()
|
||||
|
|
@ -75,7 +72,13 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true });
|
||||
|
||||
const toolsPath = path.join(config.get('downloadPath'), "tools");
|
||||
const existingRclones = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath }));
|
||||
await ensureDir(toolsPath);
|
||||
const binaryMap: Record<string, string> = {
|
||||
win32: '**/rclone.exe',
|
||||
linux: '**/rclone',
|
||||
darwin: '**/rclone'
|
||||
};
|
||||
const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath }));
|
||||
if (existingRclones[0])
|
||||
{
|
||||
this.rclonePath = path.join(toolsPath, existingRclones[0]);
|
||||
|
|
@ -83,13 +86,19 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
return;
|
||||
}
|
||||
|
||||
if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64')))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.setProgress(0.5, "Downloading RClone");
|
||||
const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`);
|
||||
const platformMap: Record<string, string> = {
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
darwin: "osx"
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64"
|
||||
};
|
||||
const downloadUrl = `https://downloads.rclone.org/rclone-current-${platformMap[process.platform]}-${archMap[process.arch]}.zip`;
|
||||
console.log("Starting Download", downloadUrl);
|
||||
const rcCloseZip = await fetch(downloadUrl);
|
||||
|
||||
await ensureDir(toolsPath);
|
||||
await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath }));
|
||||
|
|
@ -97,6 +106,7 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
if (dests[0])
|
||||
{
|
||||
this.rclonePath = path.join(toolsPath, dests[0]);
|
||||
await fs.chmod(this.rclonePath, 0o755);
|
||||
await this.startServer(ctx);
|
||||
return;
|
||||
}
|
||||
|
|
@ -139,7 +149,12 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
if (data.level === 'error')
|
||||
{
|
||||
console.error(data.msg);
|
||||
} else
|
||||
} else if (data.level === 'critical')
|
||||
{
|
||||
console.error(data.msg);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
console.log(e);
|
||||
if (loginTokenUrlRegex.test(data.msg))
|
||||
|
|
@ -150,7 +165,7 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
|
||||
});
|
||||
|
||||
await new Promise((resolve) =>
|
||||
await new Promise((resolve, reject) =>
|
||||
{
|
||||
const handleResolve = (line: string) =>
|
||||
{
|
||||
|
|
@ -160,6 +175,7 @@ export default class RcloneIntegration implements PluginType<SettingsType>
|
|||
resolve(data);
|
||||
};
|
||||
rl.on('line', handleResolve);
|
||||
setTimeout(() => { reject("Timeout"); }, 5000);
|
||||
});
|
||||
|
||||
await this.refresh();
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
release: "metadatum.first_release_date"
|
||||
};
|
||||
|
||||
async checkRemote ()
|
||||
{
|
||||
if (!config.has('rommAddress')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateClient ()
|
||||
{
|
||||
client.setConfig({
|
||||
|
|
@ -141,6 +147,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
|
||||
{
|
||||
|
||||
|
|
@ -173,6 +180,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (source && source !== 'romm') return;
|
||||
|
||||
const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true });
|
||||
|
|
@ -185,12 +193,14 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.auth.loginComplete.tapPromise(desc.name, async ({ service }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (service !== 'romm') return;
|
||||
await this.updateClient();
|
||||
});
|
||||
|
||||
ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (source !== 'romm') return;
|
||||
|
||||
const rom = await getRomApiRomsIdGet({ path: { id: Number(id) } });
|
||||
|
|
@ -205,6 +215,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (source !== 'romm') return;
|
||||
|
||||
const rom = (await getRomApiRomsIdGet({ path: { id: Number(id) }, throwOnError: true })).data;
|
||||
|
|
@ -260,12 +271,13 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
extract_path
|
||||
};
|
||||
|
||||
return info;
|
||||
return [info];
|
||||
|
||||
});
|
||||
|
||||
ctx.hooks.emulators.fetchBiosDownload.tapPromise(desc.name, async ({ systems, biosFolder }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
const files: DownloadFileEntry[] = [];
|
||||
const allRommPlatforms = await this.getAllRommPlatforms();
|
||||
|
||||
|
|
@ -296,6 +308,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
const rommPlatforms = await this.getAllRommPlatforms();
|
||||
if (rommPlatforms)
|
||||
{
|
||||
|
|
@ -313,7 +326,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) =>
|
||||
{
|
||||
|
||||
if (!await this.checkRemote()) return;
|
||||
const rommPlatforms = await this.getAllRommPlatforms();
|
||||
const systemsRommSlugSet = new Set(systems.filter(s => s.romm_slug).map(s => s.romm_slug!));
|
||||
if (rommPlatforms)
|
||||
|
|
@ -343,6 +356,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchPlatform.tapPromise(desc.name, async ({ source, id }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (source !== 'romm') return;
|
||||
const { data: rommPlatform } = await getPlatformApiPlatformsIdGet({ path: { id: Number(id) } });
|
||||
if (rommPlatform)
|
||||
|
|
@ -365,7 +379,13 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchPlatforms.tapPromise(desc.name, async ({ platforms }) =>
|
||||
{
|
||||
const rommPlatforms = await this.getAllRommPlatforms();
|
||||
if (!await this.checkRemote()) return;
|
||||
const rommPlatforms = await this.getAllRommPlatforms().catch(e =>
|
||||
{
|
||||
console.error(e);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (rommPlatforms)
|
||||
{
|
||||
const frontEndPlatforms = await Promise.all(rommPlatforms.map(async p =>
|
||||
|
|
@ -401,6 +421,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (source !== 'romm' || !ctx.config.get('savesSync')) return;
|
||||
if (!saveFolderSlots) return;
|
||||
|
||||
|
|
@ -445,6 +466,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
// Should run after emulators decide on saves
|
||||
ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (source !== 'romm' || !ctx.config.get('savesSync')) return;
|
||||
|
||||
const sourceValidation = await validateGameSource(source, id);
|
||||
|
|
@ -529,6 +551,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
const rommCollections = await getCollectionsApiCollectionsGet();
|
||||
if (rommCollections.response.ok && rommCollections.data)
|
||||
{
|
||||
|
|
@ -549,6 +572,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.fetchCollection.tapPromise(desc.name, async ({ source, id }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (source !== 'romm') return;
|
||||
const collection = await getCollectionApiCollectionsIdGet({ path: { id: Number(id) } });
|
||||
if (collection.data)
|
||||
|
|
@ -567,6 +591,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
let platform: PlatformSchema | undefined = undefined;
|
||||
|
||||
if (id && source)
|
||||
|
|
@ -587,6 +612,7 @@ export default class RommIntegration implements PluginType<SettingsType>
|
|||
|
||||
ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) =>
|
||||
{
|
||||
if (!await this.checkRemote()) return;
|
||||
if (source !== 'romm') return;
|
||||
const roms = await getRomByMetadataProviderApiRomsByMetadataProviderGet({ query: { igdb_id, ra_id } });
|
||||
if (roms.error) throw roms.error;
|
||||
|
|
|
|||
|
|
@ -50,16 +50,16 @@ function convertStoreMediaToPath (c: string)
|
|||
|
||||
export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
|
||||
{
|
||||
const validDownload = getValidDownload(storeGame);
|
||||
const validDownloads = getValidDownloads(storeGame);
|
||||
|
||||
let platform_slug: string | null = null;
|
||||
let platform_id: number | null = null;
|
||||
let platform_display_name: string | null = null;
|
||||
let path_platform_cover: string | null = null;
|
||||
|
||||
if (validDownload?.system)
|
||||
if (validDownloads.length > 0 && validDownloads[0].system)
|
||||
{
|
||||
let system = validDownload.system.split(':')[0];
|
||||
let system = validDownloads[0].system.split(':')[0];
|
||||
if (system === 'win32') system = 'win';
|
||||
|
||||
const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } });
|
||||
|
|
@ -130,13 +130,13 @@ export async function convertStoreToFrontend (id: string, storeGame: StoreGameTy
|
|||
|
||||
export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise<FrontEndGameTypeDetailed>
|
||||
{
|
||||
const validDownload = getValidDownload(storeGame);
|
||||
const validDownloads = getValidDownloads(storeGame);
|
||||
let size: number | null = null;
|
||||
if (validDownload?.url)
|
||||
if (validDownloads.length > 0 && validDownloads[0].url)
|
||||
{
|
||||
try
|
||||
{
|
||||
const fileResponse = await fetch(validDownload?.url, { method: 'HEAD' });
|
||||
const fileResponse = await fetch(validDownloads[0]?.url, { method: 'HEAD' });
|
||||
size = Number(fileResponse.headers.get('content-length'));
|
||||
} catch (error)
|
||||
{
|
||||
|
|
@ -167,25 +167,32 @@ export async function convertStoreToFrontendDetailed (id: string, storeGame: Sto
|
|||
return detailed;
|
||||
}
|
||||
|
||||
export function getValidDownload (game: StoreGameType, downloadId?: string)
|
||||
export function getValidDownloads (game: StoreGameType, downloadId?: string)
|
||||
{
|
||||
const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d }));
|
||||
const supportedDownloads = downloads.filter(d => d.type === 'direct');
|
||||
|
||||
if (downloadId)
|
||||
{
|
||||
return supportedDownloads.find(d => d.id === downloadId);
|
||||
return supportedDownloads.filter(d => d.id === downloadId);
|
||||
} else
|
||||
{
|
||||
return supportedDownloads.find(d => d.system === `${process.platform}:${process.arch}`)
|
||||
?? supportedDownloads.find(d =>
|
||||
{
|
||||
// Linux supports proton, can run windows games
|
||||
if (process.platform === 'linux') return d.system === `win32:${process.arch}`;
|
||||
return false;
|
||||
})
|
||||
// Fallback to emulator platforms
|
||||
?? supportedDownloads.find(d => !d.system.includes(':'));
|
||||
return supportedDownloads.filter(d =>
|
||||
{
|
||||
if (d.system === `${process.platform}:${process.arch}`) return true;
|
||||
|
||||
// TODO: Add linux proton support
|
||||
//if (process.platform === 'linux' && d.system === `win32:${process.arch}`) return true;
|
||||
|
||||
// emulator fallback
|
||||
return !d.system.includes(':');
|
||||
}).toSorted((a, b) =>
|
||||
{
|
||||
const bScore = b.system.includes(':') ? 0 : 1;
|
||||
const aScore = a.system.includes(':') ? 0 : 1;
|
||||
|
||||
return bScore - aScore;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -283,7 +290,8 @@ export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageT
|
|||
systems,
|
||||
gameCount: 0,
|
||||
validSources: execPaths,
|
||||
integrations: []
|
||||
integrations: [],
|
||||
source: "store"
|
||||
};
|
||||
|
||||
return em;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema";
|
||||
import desc from './package.json';
|
||||
import path, { basename, dirname } from 'node:path';
|
||||
import { StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants";
|
||||
import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService";
|
||||
import { Glob, pathToFileURL } from "bun";
|
||||
import { getOrCached } from "@/bun/api/cache";
|
||||
import { shuffleInPlace } from "@/bun/utils";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import * as emulatorSchema from '@schema/emulators';
|
||||
|
||||
import { config, db, emulatorsDb, plugins, taskQueue } from "@/bun/api/app";
|
||||
import { config, emulatorsDb, taskQueue } from "@/bun/api/app";
|
||||
import fs from "node:fs/promises";
|
||||
import { getSourceGameDetailed } from "@/bun/api/games/services/utils";
|
||||
import mustache from "mustache";
|
||||
import os from 'node:os';
|
||||
import UpdateStoreJob from "@/bun/api/jobs/update-store";
|
||||
import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService";
|
||||
import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownload } from "./services";
|
||||
import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services";
|
||||
|
||||
export default class RommIntegration implements PluginType
|
||||
{
|
||||
|
|
@ -29,11 +24,13 @@ export default class RommIntegration implements PluginType
|
|||
|
||||
async load (ctx: PluginLoadingContextType)
|
||||
{
|
||||
await this.setup(ctx);
|
||||
|
||||
ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) =>
|
||||
{
|
||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
||||
const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!);
|
||||
if (!emulatorPackage) return;
|
||||
const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage);
|
||||
return downloadInfo;
|
||||
});
|
||||
|
||||
|
|
@ -131,7 +128,7 @@ export default class RommIntegration implements PluginType
|
|||
|
||||
ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) =>
|
||||
{
|
||||
if (source !== 'store' || !gamePath || systemSlug !== 'win') return;
|
||||
if (source !== 'store' || !gamePath) return;
|
||||
const downloadPath = config.get('downloadPath');
|
||||
const gamePathAbsolute = path.join(downloadPath, gamePath);
|
||||
if (!(await fs.exists(gamePathAbsolute))) return;
|
||||
|
|
@ -139,13 +136,15 @@ export default class RommIntegration implements PluginType
|
|||
|
||||
if (gamePathStat.isDirectory())
|
||||
{
|
||||
if (!mainGlob && systemSlug !== 'win') return;
|
||||
const fileGlob = new Glob(mainGlob ?? '**/*.exe');
|
||||
for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) }))
|
||||
{
|
||||
return [{
|
||||
startDir: path.join(downloadPath, gamePath, dirname(file)),
|
||||
command: basename(file),
|
||||
id: 'store-win',
|
||||
command: `./${basename(file)}`,
|
||||
id: `store-${process.platform}`,
|
||||
shell: false,
|
||||
valid: true,
|
||||
env: {
|
||||
XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '')
|
||||
|
|
@ -160,12 +159,13 @@ export default class RommIntegration implements PluginType
|
|||
{
|
||||
return [{
|
||||
startDir: path.join(downloadPath, dirname(gamePath)),
|
||||
command: basename(gamePath),
|
||||
command: `./${basename(gamePath)}`,
|
||||
env: {
|
||||
XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '')
|
||||
},
|
||||
id: 'store-win',
|
||||
id: `store-${process.platform}`,
|
||||
valid: true,
|
||||
shell: false,
|
||||
metadata: {
|
||||
romPath: path.join(downloadPath, gamePath)
|
||||
}
|
||||
|
|
@ -272,14 +272,15 @@ export default class RommIntegration implements PluginType
|
|||
const game = await getStoreGame(id);
|
||||
if (!game) throw new Error("Missing Store Game");
|
||||
|
||||
const validDownload = getValidDownload(game, downloadId);
|
||||
const validDownloads = getValidDownloads(game, downloadId);
|
||||
|
||||
if (validDownload)
|
||||
return validDownloads.map(validDownload =>
|
||||
{
|
||||
let system = validDownload.system.split(":")[0];
|
||||
if (system === 'win32') system = 'win';
|
||||
|
||||
const info: DownloadInfo = {
|
||||
id: validDownload.id,
|
||||
coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "",
|
||||
screenshotUrls: game.screenshots ?? [],
|
||||
files: [{
|
||||
|
|
@ -306,7 +307,7 @@ export default class RommIntegration implements PluginType
|
|||
};
|
||||
|
||||
return info;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -126,7 +126,14 @@ export const store = new Elysia({ prefix: '/api/store' })
|
|||
})
|
||||
.get('/emulator/:id', async ({ params: { id } }) =>
|
||||
{
|
||||
return plugins.hooks.store.fetchEmulator.promise({ id });
|
||||
const emulator = await plugins.hooks.store.fetchEmulator.promise({ id });
|
||||
if (!emulator) return status("Not Found");
|
||||
const sources: EmulatorSourceEntryType[] = [];
|
||||
await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources });
|
||||
const integrations = findEmulatorPluginIntegration(emulator.name, sources);
|
||||
emulator.validSources = sources;
|
||||
emulator.integrations = integrations;
|
||||
return emulator;
|
||||
}, { params: z.object({ id: z.string() }) })
|
||||
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { spawnSync } from "bun";
|
||||
import { Glob, spawnSync } from "bun";
|
||||
import { platform } from "node:os";
|
||||
import { RunBrowserType } from "./browser-spawner";
|
||||
import path from 'node:path';
|
||||
|
|
@ -48,12 +48,17 @@ const ARCH_MAP: Record<string, Record<string, string>> = {
|
|||
};
|
||||
|
||||
/** The expected binary path per platform after extraction */
|
||||
function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): string
|
||||
async function getBundledBinaryPath (outDir: string, version: string, platform: string, arch: string): Promise<string | undefined>
|
||||
{
|
||||
const subFolder = `ungoogled-chromium_${version}_${PLATFORM_MAP[platform]}_${ARCH_MAP[platform][arch]}`;
|
||||
if (platform === "linux") return path.join(outDir, subFolder, "chrome");
|
||||
if (platform === "darwin") return path.join(outDir, "Chromium.app");
|
||||
return path.join(outDir, subFolder, "chrome.exe");
|
||||
let glob: Glob | undefined = undefined;
|
||||
if (platform === "linux") glob = new Glob(`**/chrome`);
|
||||
else if (platform === "darwin") glob = new Glob(`**/Chromium.app`);
|
||||
else glob = new Glob(`**/chrome.exe`);
|
||||
|
||||
for await (const bin of glob.scan({ cwd: outDir }))
|
||||
{
|
||||
return path.join(outDir, bin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -101,10 +106,14 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise<B
|
|||
if (await versionFile.exists())
|
||||
{
|
||||
const getVerstion = await versionFile.text();
|
||||
const binPath = getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch);
|
||||
if (await Bun.file(binPath).exists())
|
||||
const binPath = await getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch);
|
||||
if (binPath)
|
||||
{
|
||||
return { path: binPath, type: "chromium", source: "bundled" };
|
||||
console.log("Found Bundled Chromium Version", binPath);
|
||||
if (await Bun.file(binPath).exists())
|
||||
{
|
||||
return { path: binPath, type: "chromium", source: "bundled" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
|
|||
onFocus={(focusKey, node, details) =>
|
||||
{
|
||||
data.game.onFocus?.(focusKey, node, details);
|
||||
data.onFocus?.(focusKey, node, details);
|
||||
data.onFocus?.(focusKey, node, { ...details, id: data.game.id });
|
||||
}}
|
||||
onAction={handleAction}
|
||||
preview={preview}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function ContextList (data: {
|
|||
{
|
||||
const context = useContext(ContextDialogContext);
|
||||
return <ul className={twMerge("list gap-1", data.className)}>
|
||||
{data.options?.map(o => <OptionElement className="list-row" key={o.id} {...o} />)}
|
||||
{data.options?.map((o, i) => <OptionElement className="list-row" key={i} {...o} />)}
|
||||
{data.showCloseButton !== false && <div className="divider m-0 "></div>}
|
||||
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
|
||||
</ul>;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { getErrorMessage } from "react-error-boundary";
|
|||
import toast from "react-hot-toast";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
|
||||
import { Clock, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react";
|
||||
import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react";
|
||||
import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm";
|
||||
import ActionButton from "./ActionButton";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { DownloadSourceType } from "@/shared/constants";
|
||||
|
||||
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
|
||||
{
|
||||
|
|
@ -29,6 +30,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [details, setDetails] = useState<string | undefined>(undefined);
|
||||
const [installSources, setInstallSources] = useState<DownloadSourceType[] | undefined>(undefined);
|
||||
const [commands, setCommands] = useState<CommandEntry[] | undefined>(undefined);
|
||||
const [preferredCommand, setPreferredCommand] = useLocalStorage<string | number | undefined>(`${data.game?.source ?? data.game?.id.source}-${data.game?.source_id ?? data.game?.id.id}-preferred-command`, undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -51,6 +53,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
setProgress((e.data as any).progress);
|
||||
setDetails((e.data as any).details);
|
||||
setCommands((e.data as any).commands);
|
||||
setInstallSources((e.data as any).sources);
|
||||
|
||||
if (e.data.status === 'refresh')
|
||||
{
|
||||
|
|
@ -154,7 +157,11 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
let icon = <span className="loading loading-spinner loading-lg"></span>;
|
||||
if (status === 'install')
|
||||
{
|
||||
icon = <Download />;
|
||||
if (installSources && installSources.length > 1)
|
||||
icon = <Crosshair />;
|
||||
else
|
||||
icon = <Download />;
|
||||
|
||||
} else if (status === 'present')
|
||||
{
|
||||
icon = <Import />;
|
||||
|
|
@ -168,7 +175,14 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
{
|
||||
case 'present':
|
||||
case 'install':
|
||||
installMut.mutate({});
|
||||
if (installSources && installSources.length > 1)
|
||||
{
|
||||
showInstallSource(true, 'mainAction');
|
||||
} else
|
||||
{
|
||||
installMut.mutate({});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}}
|
||||
|
|
@ -211,6 +225,19 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
}]} />
|
||||
});
|
||||
|
||||
const { dialog: installSourcesDialog, setOpen: showInstallSource } = useContextDialog('install-source-dialog', {
|
||||
content: <ContextList options={installSources?.map(s => ({
|
||||
content: s.name,
|
||||
action (ctx)
|
||||
{
|
||||
installMut.mutate({ downloadId: s.id });
|
||||
ctx.close();
|
||||
},
|
||||
type: 'primary',
|
||||
id: s.id
|
||||
} satisfies DialogEntry)) ?? []} />
|
||||
});
|
||||
|
||||
return <div className="flex gap-2">
|
||||
{mainButton}
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
|
|
@ -222,6 +249,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
<progress className="progress progress-secondary w-full" value={progress} max="100"></progress>
|
||||
</div>
|
||||
</ActionButton>}
|
||||
{installSourcesDialog}
|
||||
{installOptionsDialog}
|
||||
{allCommandDialog}
|
||||
</div>;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function GamesSection (data: {
|
|||
<Carousel controlsClassName="z-20" scrollRef={containerRef} className="flex *:w-[18rem] *:min-w-[18rem] *:h-[21rem] overflow-y-hidden overflow-x-auto hide-scrollbar p-4 gap-4 justify-center-safe">
|
||||
{data.games?.map((g, i) => <FrontEndGameCard
|
||||
showSource={data.showSources}
|
||||
key={g.id.id}
|
||||
key={i}
|
||||
game={g}
|
||||
onAction={() => data.onSelect?.(g.id, FOCUS_KEYS.GAME_CARD(g.id))}
|
||||
onFocus={(key, node, details) => scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' })}
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ function RouteComponent ()
|
|||
|
||||
useOnNavigateBack((s) => s.sound = 'returnDetails');
|
||||
|
||||
const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists));
|
||||
const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists) || e.source === 'store');
|
||||
|
||||
const { ref: intersct } = useIntersectionObserver({
|
||||
onChange: (isIntersecting, entry) =>
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ function HomeList (data: {
|
|||
saveChildFocus="session"
|
||||
onFocus={(l, n, d) =>
|
||||
{
|
||||
const [source, id] = l.split('@', 1);
|
||||
const [source, id] = d.id?.split('@', 2);
|
||||
queryClient.prefetchQuery(gameQuery(source, id));
|
||||
handleNodeFocus(l, n, d);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ function Settings ()
|
|||
return "settings";
|
||||
})).map(([cat, data]) =>
|
||||
{
|
||||
return <div className='flex flex-col gap-1'>
|
||||
return <div key={cat} className='flex flex-col gap-1'>
|
||||
<div className="divider">{cat !== "settings" ? cat : <><Settings2 className='size-14' /> Settings</>}</div>
|
||||
{data?.map(([key, prop]) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -357,6 +357,41 @@ export function RouteComponent ()
|
|||
|
||||
const stats: StatEntry[] = [];
|
||||
|
||||
if (emulator)
|
||||
{
|
||||
if (emulator.keywords)
|
||||
stats.push({ label: "Tags", content: emulator.keywords });
|
||||
if (emulator.storeDownloadInfo)
|
||||
stats.push({ label: "Version", content: `${emulator.storeDownloadInfo.version ?? "Unknown"} (${emulator.storeDownloadInfo.type})` });
|
||||
stats.push({ label: "Systems", content: emulator.systems.map(s => s.name) });
|
||||
stats.push(...emulator.validSources.flatMap(s => [{
|
||||
label: "Source", content: <div className="flex flex-col grow">
|
||||
<div className="flex grow flex-wrap justify-between gap-1">
|
||||
<div className="flex gap-1">{emulatorStatusIcons[s.type]}{s.type}</div>
|
||||
<div className="text-base-content/40">{s.binPath}</div>
|
||||
</div>
|
||||
{emulator.integrations.some(i => i.source?.type === s.type) && <div className="divider m-0"></div>}
|
||||
{emulator.integrations.filter(i => i.source?.type === s.type).map(i =>
|
||||
{
|
||||
return <div key={i.id} className="flex flex-wrap justify-between gap-1">
|
||||
<div className="flex gap-2">
|
||||
<Puzzle />
|
||||
<div>{i.id}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap text-base-content/40">
|
||||
{i.capabilities?.map(c => <><div className="divider divider-horizontal"></div><div className="flex gap-1">{capabilityIconMap[c]}{c}</div></>)}
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
}]));
|
||||
if (emulator.bios)
|
||||
stats.push({
|
||||
label: "Bios", content: emulator.bios && emulator.bios.length > 0 ? emulator.bios : <div className="text-warning font-semibold">Missing</div>
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} className="" scrolling>
|
||||
<AutoFocus focus={focusSelf} />
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export const installMutation = (source: string, id: string) => mutationOptions({
|
|||
mutationKey: ['install', source, id],
|
||||
mutationFn: async (init: { downloadId?: string; }) =>
|
||||
{
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ query: { downloadId: init.downloadId } });
|
||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ downloadId: init.downloadId });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ export const GameListFilterSchema = z.object({
|
|||
keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(),
|
||||
});
|
||||
|
||||
export const DownloadSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
});
|
||||
|
||||
export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() });
|
||||
|
||||
export type GameListFilterType = z.infer<typeof GameListFilterSchema>;
|
||||
|
|
@ -208,3 +213,4 @@ export type LocalSettingsType = z.infer<typeof LocalSettingsSchema>;
|
|||
export const PlatformSchema = z.object({ slug: z.string() });
|
||||
export type SystemInfoType = z.infer<typeof SystemInfoSchema>;
|
||||
export type EmulatorDownloadInfoType = z.infer<typeof EmulatorDownloadInfoSchema>;
|
||||
export type DownloadSourceType = z.infer<typeof DownloadSourceSchema>;
|
||||
|
|
|
|||
3
src/shared/types..d.ts
vendored
3
src/shared/types..d.ts
vendored
|
|
@ -126,6 +126,8 @@ declare interface CommandEntry
|
|||
startDir?: string;
|
||||
/** Is the command valid, for example does the executable exists */
|
||||
valid: boolean;
|
||||
/** Run the command as shell. Defaults is true */
|
||||
shell?: boolean;
|
||||
/** For what emulator is the command */
|
||||
emulator?: string;
|
||||
/** Where the emulator came from */
|
||||
|
|
@ -252,6 +254,7 @@ declare type KeysWithValueAssignableTo<T, Value> = {
|
|||
|
||||
declare interface DownloadInfo
|
||||
{
|
||||
id: string;
|
||||
screenshotUrls: string[];
|
||||
coverUrl: string;
|
||||
platform?: DownloadPlatform;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue