From ccc5a05ed7010adea77eea9190f3149b67702b39 Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 30 Mar 2026 20:00:08 +0300 Subject: [PATCH] fix: Issues with launching and installation on the steam deck --- package.json | 4 +- scripts/dev.ts | 13 ++-- src/bun/api/controls/gamepad.ts | 24 ++++--- .../api/games/services/launchGameService.ts | 63 +++++++++++++++---- src/bun/api/games/services/statusService.ts | 7 ++- src/bun/api/games/services/utils.ts | 2 +- src/bun/api/jobs/emulator-download-job.ts | 11 +++- src/bun/api/jobs/install-job.ts | 4 +- src/bun/api/jobs/launch-game-job.ts | 23 ++++--- .../pcsx2.ts | 8 ++- .../ppsspp.ts | 25 ++++++-- .../com.simeonradivoev.gameflow.romm/romm.ts | 2 +- src/bun/utils.ts | 30 ++++++++- src/bun/utils/downloader.ts | 3 +- src/bun/utils/get-browser.ts | 13 ++-- src/mainview/components/ContextDialog.tsx | 21 +++++-- .../components/game/ActionButtons.tsx | 44 ++++++++----- src/mainview/components/game/MainActions.tsx | 18 ++++-- src/mainview/scripts/queries/romm.ts | 12 +++- 19 files changed, 247 insertions(+), 80 deletions(-) diff --git a/package.json b/package.json index d43210f..0d5efe3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/dev.ts b/scripts/dev.ts index 735efa1..ef3ad70 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -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(); -}); \ No newline at end of file + spawnBrowser()?.then(async e => + { + console.log("Sending exit Signal to server"); + await server.stdin.write('shutdown\n'); + await server.stdin.flush(); + }); +} \ No newline at end of file diff --git a/src/bun/api/controls/gamepad.ts b/src/bun/api/controls/gamepad.ts index e58d615..7e94a3b 100644 --- a/src/bun/api/controls/gamepad.ts +++ b/src/bun/api/controls/gamepad.ts @@ -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?.(); } } \ No newline at end of file diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index c1b027f..add3c59 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -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 { 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); } diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index b1ac7b9..af9e62a 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -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({}); diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 91dff60..f40eb8a 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -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) diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 54af36d..da87ccc 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -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 @@ -108,6 +108,13 @@ export class EmulatorDownloadJob implements IJob 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 }))); } + this.localGameId = id; }); } else { diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 4265ae3..4572fde 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -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, "playing"> { @@ -43,34 +42,44 @@ export class LaunchGameJob implements IJob { - 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")); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 253bc47..072752b 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -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; } diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index a963cd1..8384213 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -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; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 3ec5287..654fb2a 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -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 }; diff --git a/src/bun/utils.ts b/src/bun/utils.ts index 02b8d13..c21a78b 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -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); + }); + } + } } \ No newline at end of file diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index ea0a6b2..e288e8c 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -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); diff --git a/src/bun/utils/get-browser.ts b/src/bun/utils/get-browser.ts index f3a473d..ffcf07c 100644 --- a/src/bun/utils/get-browser.ts +++ b/src/bun/utils/get-browser.ts @@ -97,12 +97,17 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise {data.options?.map(o => )}
- {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />} + {data.showCloseButton !== false && } action={() => context.close()} id="close-context-dialog" content="Close" />} ; } -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
  • @@ -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(undefined); const handleClose = (value: boolean, newSourceFocusKey?: string) => { + if (data.canClose === false) return; if (value === open) return; if (value) { diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 1ecacd9..3a7eb98 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -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: , - content: "Delete", - type: 'error' - }); + if (deleteMutation.isPending) + { + contextOptions.push({ + id: 'delete', + icon: , + content: "Deleting", + type: 'error' + }); + } else + { + contextOptions.push({ + id: 'delete', + action: () => + { + deleteMutation.mutate(); + }, + icon: , + content: "Delete", + type: 'error' + }); + } } - const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: }); + const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending }); return
    diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index 5a3b995..b2ae320 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -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: + content: { 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); diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 3165f52..67dcc86 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -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; + }, }); \ No newline at end of file