fix: Issues with launching and installation on the steam deck

This commit is contained in:
Simeon Radivoev 2026-03-30 20:00:08 +03:00
parent dc0f2d150a
commit ccc5a05ed7
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
19 changed files with 247 additions and 80 deletions

View file

@ -15,8 +15,8 @@
"scripts": {
"dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'",
"dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'",
"dev:bun:hmr": "PUBLIC_ACCESS=true NODE_ENV=development conc 'bun run hmr' 'bun run --watch ./src/bun/index.ts",
"dev:bun": "NODE_ENV=development bun run build:vite && conc 'bun run ./src/bun/index.ts",
"dev:bun:hmr": "PUBLIC_ACCESS=true NODE_ENV=development conc 'bun run hmr' 'bun run --watch ./src/bun/index.ts'",
"dev:bun": "NODE_ENV=development bun run build:vite && conc 'bun run ./src/bun/index.ts'",
"build:vite": "bun run --bun vite build",
"build:prod:vite": "NODE_ENV=production bun run build:vite",
"build:dev:vite": "NODE_ENV=development bun run build:vite",

View file

@ -67,9 +67,12 @@ function spawnBrowser ()
}
let server = spawnServer();
spawnBrowser()?.then(async e =>
if (!process.env.HEADLESS)
{
console.log("Sending exit Signal to server");
await server.stdin.write('shutdown\n');
await server.stdin.flush();
});
spawnBrowser()?.then(async e =>
{
console.log("Sending exit Signal to server");
await server.stdin.write('shutdown\n');
await server.stdin.flush();
});
}

View file

@ -1,32 +1,38 @@
// ./gamepad/index.ts
import { platform } from "os";
import { GamepadWindows } from "./windows";
import { GamepadLinux } from "./linux";
import type { IGamepadBackend, GamepadState } from "./types";
export class Gamepad
{
private backend: IGamepadBackend;
private index: number;
private backend: IGamepadBackend | undefined;
constructor(index = 0)
{
if (platform() === "win32")
this.index = index;
}
async init ()
{
if (process.platform === "win32")
{
this.backend = new GamepadWindows(index);
const { GamepadWindows } = await import("./windows");
this.backend = new GamepadWindows(this.index);
} else
{
this.backend = new GamepadLinux(index);
const { GamepadLinux } = await import("./linux");
this.backend = new GamepadLinux(this.index);
}
}
update (): GamepadState | null
{
return this.backend.update();
return this.backend?.update() ?? null;
}
close ()
{
this.backend.close?.();
this.backend?.close?.();
}
}

View file

@ -1,13 +1,15 @@
import path from 'node:path';
import { which } from 'bun';
import { Glob, which } from 'bun';
import fs from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import * as schema from '@schema/emulators';
import { eq } from 'drizzle-orm';
import { config, customEmulators, emulatorsDb, taskQueue } from '../../app';
import os from 'node:os';
import os, { platform } from 'node:os';
import { cores } from '../../emulatorjs/emulatorjs';
import { LaunchGameJob } from '../../jobs/launch-game-job';
import { EmulatorPackageType } from '@/shared/constants';
import { getStoreEmulatorPackage, getStoreFolder } from '../../store/services/gamesService';
export const varRegex = /%([^%]+)%/g;
export const assignRegex = /(%\w+%)=(\S+) /g;
@ -129,10 +131,22 @@ export async function getValidLaunchCommands (data: {
function escapeWindowsArg (arg: string): string
{
return `"${arg
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
}"`;
if (process.platform === 'win32')
{
return `"${arg
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
}"`;
} else
{
if (arg.includes(' '))
{
return `"${arg}"`;
} else
{
return arg;
}
}
}
const formattedCommands = await Promise.all(system.commands
@ -196,7 +210,10 @@ export async function getValidLaunchCommands (data: {
return [
[value, validExec ? validExec.binPath : undefined] as [string, string | undefined],
[`%EMUSOURCE%`, validExec?.type] as [string, string | undefined],
['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined]];
['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined],
['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined]
];
}
const key = value[0].substring(1, value.length - 1);
@ -233,9 +250,9 @@ export async function getValidLaunchCommands (data: {
valid: !invalid, emulator,
emulatorSource: vars['%EMUSOURCE%'] as any,
metadata: {
romPath: staticVars['%ROM%'],
romPath: validFiles[0],
emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1],
emulatorDir: vars['%EMUDIR%']
emulatorDir: vars['%EMUDIRRAW%']
}
} satisfies CommandEntry;
}));
@ -253,7 +270,7 @@ export async function findExecsByName (emulatorName: string)
return findExecs(emulatorName, emulator);
}
export function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): EmulatorSourceEntryType | undefined
export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise<EmulatorSourceEntryType | undefined>
{
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name)));
@ -262,6 +279,30 @@ export function findStoreEmulatorExec (id: string, emulator?: { systempath: stri
return { binPath: path.join(storeEmulatorFolder, storeExecName), rootPath: storeEmulatorFolder, exists: true, type: "store" };
}
const storeEmulator = await getStoreEmulatorPackage(id);
if (storeEmulator?.downloads)
{
const storeExecName = (await Promise.all(storeEmulator.downloads[`${process.platform}:${process.arch}`].map(async dl =>
{
// glob file search causes issues so do manual search
const glob = new Glob(dl.pattern);
if (await fs.exists(storeEmulatorFolder))
{
const files = (await fs.readdir(storeEmulatorFolder))
.filter(f => glob.match(f));
return files.map(f => path.join(storeEmulatorFolder, f));
}
return [];
}))).flatMap(f => f);
if (storeExecName.length > 0)
{
return { binPath: storeExecName[0], rootPath: storeEmulatorFolder, exists: true, type: 'store' };
}
}
return undefined;
}
@ -276,7 +317,7 @@ export async function findExecs (id: string, emulator?: { winregistrypath: strin
if (emulator && emulator.systempath.length > 0)
{
const storePath = findStoreEmulatorExec(id, emulator);
const storePath = await findStoreEmulatorExec(id, emulator);
if (storePath) execs.push(storePath);
}

View file

@ -50,7 +50,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
{
try
{
const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, customEmulatorConfig: customEmulators, gamePath: localGame.path_fs });
const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, gamePath: localGame.path_fs });
if (cores[esPlatform.system])
{
@ -103,7 +103,8 @@ export default function buildStatusResponse ()
response: z.discriminatedUnion('status', [
z.object({ status: z.literal('error'), error: z.unknown() }),
z.object({ status: z.literal('installed'), commands: z.array(z.any()), details: z.string().optional() }),
z.object({ status: z.literal(['refresh', 'queued']) }),
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('present'), details: z.string() }),
@ -241,7 +242,7 @@ export default function buildStatusResponse ()
{
if (data.id === installJobId)
{
ws.send({ status: 'refresh' });
ws.send({ status: 'refresh', localId: (data.job.job as InstallJob).localGameId });
} else if (data.job.job instanceof LaunchGameJob)
{
handleActiveExit({});

View file

@ -222,7 +222,7 @@ export async function checkFiles (files: DownloadFileEntry[], isArchive: boolean
{
// file is either zip or doesn't support sha checking
if (!f.sha1 || isArchive) return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry;
const localPath = path.join(f.file_path, f.file_name);
const localPath = path.join(config.get('downloadPath'), f.file_path, f.file_name);
if (await fs.exists(localPath))
{
if (f.size && f.size !== (await fs.stat(localPath)).size)

View file

@ -9,7 +9,7 @@ import { getOrCachedGithubRelease } from "../cache";
import Seven from 'node-7z';
import fs from "node:fs/promises";
import { Downloader } from "@/bun/utils/downloader";
import { move } from "fs-extra";
import { ensureDir, move } from "fs-extra";
import { simulateProgress } from "@/bun/utils";
type EmulatorDownloadStates = "download" | "extract";
@ -82,7 +82,7 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
{
if (isArchive)
{
if (await downloader.start() && destinationPaths[0])
if (destinationPaths[0])
{
let destinationPath = destinationPaths[0];
await new Promise((resolve, reject) =>
@ -108,6 +108,13 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
}
}
}
} else
{
await ensureDir(emulatorsFolder);
for (const destPath of destinationPaths)
{
await fs.rename(destPath, path.join(emulatorsFolder, path.basename(destPath)));
}
}
}
}

View file

@ -31,7 +31,8 @@ export class InstallJob implements IJob<never, InstallJobStates>
public gameId: string;
public source: string;
public config?: JobConfig;
// The local game ID of newly created entry, if successful
public localGameId?: number;
public group = InstallJob.id;
constructor(id: string, source: string, config?: JobConfig)
@ -252,6 +253,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
})));
}
this.localGameId = id;
});
} else
{

View file

@ -5,7 +5,6 @@ import { db, events, plugins } from "../app";
import * as appSchema from "@schema/app";
import { eq, sql } from "drizzle-orm";
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
import { killBrowser } from "@/bun/utils/browser-spawner";
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
{
@ -43,34 +42,44 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
await new Promise((resolve, reject) =>
{
let game: Bun.Subprocess;
let game: any;
if (!commandArgs)
{
game = Bun.spawn(this.validCommand.command.split(' '), {
// ES-DE commands require shell execution. Some emulators fail otherwise.
const spawnGame = spawn(this.validCommand.command, {
shell: true,
cwd: this.validCommand.startDir,
windowsVerbatimArguments: true,
signal: context.abortSignal
});
game.exited.then(resolve).catch(e =>
spawnGame.stdout.on('data', data => console.log(data));
spawnGame.on('close', (code) =>
{
resolve(code);
});
spawnGame.on('error', e =>
{
console.error(e);
reject(e);
});
game = spawnGame;
}
else if (this.validCommand.metadata.emulatorBin)
{
game = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
// We have full control over launching integrated emulators better to use bun spawn
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
cwd: this.validCommand.startDir,
windowsVerbatimArguments: true,
signal: context.abortSignal
});
game.exited.then(resolve).catch(e =>
bunGame.exited.then(resolve).catch(e =>
{
console.error(e);
reject(e);
});
game = bunGame;
} else
{
reject(new Error("No Emulator Bin"));

View file

@ -41,7 +41,13 @@ export default class PCSX2Integration implements PluginType
await Promise.all(Object.values(view).map(p => ensureDir(p)));
await Bun.write(path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis', 'PCSX2.ini'), Mustache.render(configFileContents, view));
let pscx2Path = '';
if (process.platform === 'win32')
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
else
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, "PCSX2", 'inis');
await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view));
return args;
}

View file

@ -8,6 +8,7 @@ import configControlsFilePathLinux from './linux/controls.ini' with { type: 'fil
import path from "node:path";
import Mustache from "mustache";
import { ensureDir } from "fs-extra";
import { homedir } from "node:os";
export default class PCSX2Integration implements PluginType
{
@ -38,13 +39,29 @@ export default class PCSX2Integration implements PluginType
break;
}
let ppssppPath = '';
if (process.platform === 'win32')
{
ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM');
} else
{
//TODO: Use way to set custom memstick path when they support it
ensureDir(path.join(homedir(), '.config', 'ppsspp'));
ppssppPath = path.join(homedir(), '.config', 'ppsspp', 'PSP', 'SYSTEM');
}
ensureDir(ppssppPath);
if (confPath)
{
const configFileContents = await Bun.file(confPath).text();
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {}));
}
if (controlsPath)
{
const configFileContents = await Bun.file(controlsPath).text();
const controlsFileContents = await Bun.file(controlsPath).text();
ensureDir(path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'));
await Bun.write(path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM', 'ppsspp.ini'), Mustache.render(configFileContents, {}));
await Bun.write(path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM', 'controls.ini'), Mustache.render(controlsFileContents, {}));
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
}
return args;

View file

@ -184,7 +184,7 @@ export default class RommIntegration implements PluginType
const file: DownloadFileEntry = {
url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`),
file_name: f.file_name,
file_path: path.join(config.get('downloadPath'), f.file_path),
file_path: f.file_path,
size: f.file_size_bytes,
sha1: f.sha1_hash ?? undefined
};

View file

@ -1,9 +1,8 @@
import { $, sleep } from 'bun';
import path from 'node:path';
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { SettingsType } from '@/shared/constants';
import { config } from './api/app';
import fs from 'node:fs/promises';
export function checkRunning (pid: number)
{
@ -146,4 +145,31 @@ export async function simulateProgress (setProgress: (p: number) => void, signal
if (signal && signal.aborted) return;
await sleep(1000);
}
}
export async function moveAllFiles (srcDir: string, destDir: string)
{
await fs.mkdir(destDir, { recursive: true });
const entries = await fs.readdir(srcDir);
for (const entry of entries)
{
const srcPath = path.join(srcDir, entry);
const destPath = path.join(destDir, entry);
const stats = await fs.stat(srcPath);
if (stats.isDirectory())
{
await moveAllFiles(srcPath, destPath);
await fs.rmdir(srcPath); // remove empty directory
} else
{
await fs.rename(srcPath, destPath).catch(async () =>
{
// fallback to copy+delete if rename fails
await fs.copyFile(srcPath, destPath);
await fs.unlink(srcPath);
});
}
}
}

View file

@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
import { createWriteStream } from "node:fs";
import { config, jar } from "../api/app";
import { moveAllFiles } from "../utils";
export interface ProgressStats
{
@ -207,7 +208,7 @@ export class Downloader
});
}
await move(this.tmpPath, this.downloadPath, { overwrite: true });
await moveAllFiles(this.tmpPath, this.downloadPath);
if (await fs.exists(this.tmpPath))
await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPathMeta);

View file

@ -97,12 +97,17 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise<B
// Check bundled
if (includeBundled)
{
const getVerstion = await Bun.file('./bin/chromium/.chromium-version').text();
const binPath = getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch);
if (await Bun.file(binPath).exists())
const versionFile = Bun.file('./bin/chromium/.chromium-version');
if (await versionFile.exists())
{
return { path: binPath, type: "chromium", source: "bundled" };
const getVerstion = await versionFile.text();
const binPath = getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch);
if (await Bun.file(binPath).exists())
{
return { path: binPath, type: "chromium", source: "bundled" };
}
}
}
// 1. Check for currently running browser process

View file

@ -7,17 +7,22 @@ import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"
import { ContextDialogContext } from "../scripts/contexts";
import { FOCUS_KEYS } from "../scripts/types";
export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; })
export function ContextList (data: {
options?: DialogEntry[];
className?: string;
showCloseButton?: boolean;
disableCloseButton?: boolean;
})
{
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} />)}
<div className="divider m-0 "></div>
{data.showCloseButton !== false && <OptionElement className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
</ul>;
}
export function OptionElement (data: DialogEntry & { onFocus?: () => void; className?: string; })
export function OptionElement (data: DialogEntry & { onFocus?: () => void; className?: string; disabled?: boolean; })
{
const context = useContext(ContextDialogContext);
const handleFocus = () =>
@ -25,7 +30,11 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
data.onFocus?.();
};
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
const handleAction = () =>
{
if (data.disabled === true) return;
data.action?.({ close: context.close, focus: focusSelf });
};
const { ref, focusSelf, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
onEnterPress: data.shortcuts ? undefined : handleAction,
@ -47,6 +56,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
return <li ref={ref}
onClick={handleAction}
data-selected={data.selected}
aria-disabled={data.disabled}
className={
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
<FocusContext value={focusKey}>
@ -72,12 +82,13 @@ export interface DialogEntry
shortcuts?: Shortcut[];
}
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; })
export function useContextDialog (id: string, data: { content?: JSX.Element; className?: string; preferredChildFocusKey?: string; onClose?: () => void; canClose?: boolean; })
{
const [open, setOpen] = useState(false);
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
const handleClose = (value: boolean, newSourceFocusKey?: string) =>
{
if (data.canClose === false) return;
if (value === open) return;
if (value)
{

View file

@ -1,4 +1,4 @@
import { deleteGameMutation } from "@/mainview/scripts/queries/romm";
import { deleteGameMutation, gameInvalidationQuery } from "@/mainview/scripts/queries/romm";
import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { useMutation } from "@tanstack/react-query";
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
@ -9,6 +9,8 @@ import MainActions from "./MainActions";
import ActionButton from "./ActionButton";
import { useLocalStorage } from "usehooks-ts";
import FocusTooltip from "../FocusTooltip";
import { Router } from "@/mainview";
import { useBlocker } from "@tanstack/react-router";
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams)
{
@ -35,10 +37,9 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' });
const deleteMutation = useMutation({
...deleteGameMutation({ id: data.id, source: data.source }),
onSuccess: () =>
onSuccess: (d, v, r, ctx) =>
{
location.reload();
console.log("Deleted");
ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source)).then(() => Router.history.back());
},
onError (error)
{
@ -46,22 +47,35 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
}
});
useBlocker({ shouldBlockFn: () => deleteMutation.isPending });
const contextOptions: DialogEntry[] = [];
if (data.game?.local)
{
contextOptions.push({
id: 'delete',
action: () =>
{
deleteMutation.mutate();
},
icon: <Trash />,
content: "Delete",
type: 'error'
});
if (deleteMutation.isPending)
{
contextOptions.push({
id: 'delete',
icon: <span className="loading loading-spinner loading-lg"></span>,
content: "Deleting",
type: 'error'
});
} else
{
contextOptions.push({
id: 'delete',
action: () =>
{
deleteMutation.mutate();
},
icon: <Trash />,
content: "Delete",
type: 'error'
});
}
}
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList options={contextOptions} /> });
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList disableCloseButton={deleteMutation.isPending} options={contextOptions} />, canClose: !deleteMutation.isPending });
return <div ref={ref} className="flex sm:gap-2 md:gap-4 sm:h-16 md:h-32 overflow-hidden p-2 items-center shrink-0">
<FocusContext value={focusKey}>

View file

@ -7,7 +7,7 @@ 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 { installMutation, playMutation } from "@/mainview/scripts/queries/romm";
import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm";
import ActionButton from "./ActionButton";
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
@ -53,8 +53,17 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
if (e.data.status === 'refresh')
{
queryClient.invalidateQueries({ queryKey: ['game', data.id] });
Router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true });
const localId = e.data.localId;
queryClient.refetchQueries(gameInvalidationQuery(localId ? 'local' : data.source, localId ? String(localId) : data.id)).then(() =>
{
if (localId)
{
Router.navigate({ to: '/game/$source/$id', params: { id: String(localId), source: 'local' }, replace: true });
} else
{
Router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true });
}
});
} else if (e.data.status === 'error')
{
const errorMessage = getErrorMessage(e.data.error);
@ -171,12 +180,13 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
}
const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
content: <ContextList options={validCommands.map(c =>
content: <ContextList options={validCommands.map((c, i) =>
{
const commands: DialogEntry = {
id: String(c.id),
content: c.label ?? "",
type: 'primary',
selected: preferredCommand !== undefined ? preferredCommand === c.id : i === 0,
action (ctx)
{
setPreferredCommand(c.id);

View file

@ -1,8 +1,8 @@
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
import { rommApi, settingsApi } from "../clientApi";
import { mutationOptions, queryOptions } from "@tanstack/react-query";
import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query";
import z from "zod";
import { getCollectionApiCollectionsIdGetOptions, getCollectionsApiCollectionsGetOptions, getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
export const allGamesQuery = (filter?: GameListFilterType) => queryOptions({
queryKey: ['games', filter ?? 'all'],
@ -146,4 +146,12 @@ export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) =>
if (error) throw error;
return data;
}
});
export const gameInvalidationQuery = (source: string, id: string): QueryFilters => ({
predicate (query)
{
if (query.queryKey[0] === 'games') return true;
if (query.queryKey.includes(source) && query.queryKey.includes(id)) return true;
return false;
},
});