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": { "scripts": {
"dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'", "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: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: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": "NODE_ENV=development bun run build:vite && conc 'bun run ./src/bun/index.ts'",
"build:vite": "bun run --bun vite build", "build:vite": "bun run --bun vite build",
"build:prod:vite": "NODE_ENV=production bun run build:vite", "build:prod:vite": "NODE_ENV=production bun run build:vite",
"build:dev:vite": "NODE_ENV=development 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(); let server = spawnServer();
spawnBrowser()?.then(async e => if (!process.env.HEADLESS)
{ {
console.log("Sending exit Signal to server"); spawnBrowser()?.then(async e =>
await server.stdin.write('shutdown\n'); {
await server.stdin.flush(); 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 // ./gamepad/index.ts
import { platform } from "os";
import { GamepadWindows } from "./windows";
import { GamepadLinux } from "./linux";
import type { IGamepadBackend, GamepadState } from "./types"; import type { IGamepadBackend, GamepadState } from "./types";
export class Gamepad export class Gamepad
{ {
private backend: IGamepadBackend; private index: number;
private backend: IGamepadBackend | undefined;
constructor(index = 0) 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 } else
{ {
this.backend = new GamepadLinux(index); const { GamepadLinux } = await import("./linux");
this.backend = new GamepadLinux(this.index);
} }
} }
update (): GamepadState | null update (): GamepadState | null
{ {
return this.backend.update(); return this.backend?.update() ?? null;
} }
close () close ()
{ {
this.backend.close?.(); this.backend?.close?.();
} }
} }

View file

@ -1,13 +1,15 @@
import path from 'node:path'; import path from 'node:path';
import { which } from 'bun'; import { Glob, which } from 'bun';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import * as schema from '@schema/emulators'; import * as schema from '@schema/emulators';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; import { config, customEmulators, emulatorsDb, taskQueue } from '../../app';
import os from 'node:os'; import os, { platform } from 'node:os';
import { cores } from '../../emulatorjs/emulatorjs'; import { cores } from '../../emulatorjs/emulatorjs';
import { LaunchGameJob } from '../../jobs/launch-game-job'; 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 varRegex = /%([^%]+)%/g;
export const assignRegex = /(%\w+%)=(\S+) /g; export const assignRegex = /(%\w+%)=(\S+) /g;
@ -129,10 +131,22 @@ export async function getValidLaunchCommands (data: {
function escapeWindowsArg (arg: string): string function escapeWindowsArg (arg: string): string
{ {
return `"${arg if (process.platform === 'win32')
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes {
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes 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 const formattedCommands = await Promise.all(system.commands
@ -196,7 +210,10 @@ export async function getValidLaunchCommands (data: {
return [ return [
[value, validExec ? validExec.binPath : undefined] as [string, string | undefined], [value, validExec ? validExec.binPath : undefined] as [string, string | undefined],
[`%EMUSOURCE%`, validExec?.type] 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); const key = value[0].substring(1, value.length - 1);
@ -233,9 +250,9 @@ export async function getValidLaunchCommands (data: {
valid: !invalid, emulator, valid: !invalid, emulator,
emulatorSource: vars['%EMUSOURCE%'] as any, emulatorSource: vars['%EMUSOURCE%'] as any,
metadata: { metadata: {
romPath: staticVars['%ROM%'], romPath: validFiles[0],
emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1],
emulatorDir: vars['%EMUDIR%'] emulatorDir: vars['%EMUDIRRAW%']
} }
} satisfies CommandEntry; } satisfies CommandEntry;
})); }));
@ -253,7 +270,7 @@ export async function findExecsByName (emulatorName: string)
return findExecs(emulatorName, emulator); 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 storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
const storeExecName = emulator?.systempath.find(name => existsSync(path.join(storeEmulatorFolder, name))); 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" }; 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; return undefined;
} }
@ -276,7 +317,7 @@ export async function findExecs (id: string, emulator?: { winregistrypath: strin
if (emulator && emulator.systempath.length > 0) if (emulator && emulator.systempath.length > 0)
{ {
const storePath = findStoreEmulatorExec(id, emulator); const storePath = await findStoreEmulatorExec(id, emulator);
if (storePath) execs.push(storePath); if (storePath) execs.push(storePath);
} }

View file

@ -50,7 +50,7 @@ export async function getValidLaunchCommandsForGame (source: string, id: string)
{ {
try 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]) if (cores[esPlatform.system])
{ {
@ -103,7 +103,8 @@ export default function buildStatusResponse ()
response: z.discriminatedUnion('status', [ response: z.discriminatedUnion('status', [
z.object({ status: z.literal('error'), error: z.unknown() }), 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('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('playing'), details: z.string() }),
z.object({ status: z.literal('install'), details: z.string() }), z.object({ status: z.literal('install'), details: z.string() }),
z.object({ status: z.literal('present'), details: z.string() }), z.object({ status: z.literal('present'), details: z.string() }),
@ -241,7 +242,7 @@ export default function buildStatusResponse ()
{ {
if (data.id === installJobId) 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) } else if (data.job.job instanceof LaunchGameJob)
{ {
handleActiveExit({}); 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 // file is either zip or doesn't support sha checking
if (!f.sha1 || isArchive) return { ...f, exists: false, matches: false } satisfies LocalDownloadFileEntry; 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 (await fs.exists(localPath))
{ {
if (f.size && f.size !== (await fs.stat(localPath)).size) 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 Seven from 'node-7z';
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { Downloader } from "@/bun/utils/downloader"; import { Downloader } from "@/bun/utils/downloader";
import { move } from "fs-extra"; import { ensureDir, move } from "fs-extra";
import { simulateProgress } from "@/bun/utils"; import { simulateProgress } from "@/bun/utils";
type EmulatorDownloadStates = "download" | "extract"; type EmulatorDownloadStates = "download" | "extract";
@ -82,7 +82,7 @@ export class EmulatorDownloadJob implements IJob<z.infer<typeof EmulatorDownload
{ {
if (isArchive) if (isArchive)
{ {
if (await downloader.start() && destinationPaths[0]) if (destinationPaths[0])
{ {
let destinationPath = destinationPaths[0]; let destinationPath = destinationPaths[0];
await new Promise((resolve, reject) => 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 gameId: string;
public source: string; public source: string;
public config?: JobConfig; public config?: JobConfig;
// The local game ID of newly created entry, if successful
public localGameId?: number;
public group = InstallJob.id; public group = InstallJob.id;
constructor(id: string, source: string, config?: JobConfig) constructor(id: string, source: string, config?: JobConfig)
@ -252,6 +253,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
}))); })));
} }
this.localGameId = id;
}); });
} else } else
{ {

View file

@ -5,7 +5,6 @@ import { db, events, plugins } from "../app";
import * as appSchema from "@schema/app"; import * as appSchema from "@schema/app";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; 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"> 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) => await new Promise((resolve, reject) =>
{ {
let game: Bun.Subprocess; let game: any;
if (!commandArgs) 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, cwd: this.validCommand.startDir,
windowsVerbatimArguments: true,
signal: context.abortSignal 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); console.error(e);
reject(e); reject(e);
}); });
game = spawnGame;
} }
else if (this.validCommand.metadata.emulatorBin) 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, cwd: this.validCommand.startDir,
windowsVerbatimArguments: true, windowsVerbatimArguments: true,
signal: context.abortSignal signal: context.abortSignal
}); });
game.exited.then(resolve).catch(e => bunGame.exited.then(resolve).catch(e =>
{ {
console.error(e); console.error(e);
reject(e); reject(e);
}); });
game = bunGame;
} else } else
{ {
reject(new Error("No Emulator Bin")); 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 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; return args;
} }

View file

@ -8,6 +8,7 @@ import configControlsFilePathLinux from './linux/controls.ini' with { type: 'fil
import path from "node:path"; import path from "node:path";
import Mustache from "mustache"; import Mustache from "mustache";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import { homedir } from "node:os";
export default class PCSX2Integration implements PluginType export default class PCSX2Integration implements PluginType
{ {
@ -38,13 +39,29 @@ export default class PCSX2Integration implements PluginType
break; 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) if (controlsPath)
{ {
const configFileContents = await Bun.file(controlsPath).text();
const controlsFileContents = 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(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
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, {}));
} }
return args; return args;

View file

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

View file

@ -1,9 +1,8 @@
import { $, sleep } from 'bun'; import { $, sleep } from 'bun';
import path from 'node:path'; import path from 'node:path';
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { SettingsType } from '@/shared/constants'; import { SettingsType } from '@/shared/constants';
import { config } from './api/app'; import { config } from './api/app';
import fs from 'node:fs/promises';
export function checkRunning (pid: number) export function checkRunning (pid: number)
{ {
@ -146,4 +145,31 @@ export async function simulateProgress (setProgress: (p: number) => void, signal
if (signal && signal.aborted) return; if (signal && signal.aborted) return;
await sleep(1000); 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 { createWriteStream } from "node:fs";
import { config, jar } from "../api/app"; import { config, jar } from "../api/app";
import { moveAllFiles } from "../utils";
export interface ProgressStats 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)) if (await fs.exists(this.tmpPath))
await fs.rm(this.tmpPath, { recursive: true }); await fs.rm(this.tmpPath, { recursive: true });
await fs.rm(this.tmpPathMeta); await fs.rm(this.tmpPathMeta);

View file

@ -97,12 +97,17 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise<B
// Check bundled // Check bundled
if (includeBundled) if (includeBundled)
{ {
const getVerstion = await Bun.file('./bin/chromium/.chromium-version').text(); const versionFile = Bun.file('./bin/chromium/.chromium-version');
const binPath = getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch); if (await versionFile.exists())
if (await Bun.file(binPath).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 // 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 { ContextDialogContext } from "../scripts/contexts";
import { FOCUS_KEYS } from "../scripts/types"; 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); const context = useContext(ContextDialogContext);
return <ul className={twMerge("list gap-1", data.className)}> 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 => <OptionElement className="list-row" key={o.id} {...o} />)}
<div className="divider m-0 "></div> <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>; </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 context = useContext(ContextDialogContext);
const handleFocus = () => const handleFocus = () =>
@ -25,7 +30,11 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' }); (ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
data.onFocus?.(); 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({ const { ref, focusSelf, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id), focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
onEnterPress: data.shortcuts ? undefined : handleAction, onEnterPress: data.shortcuts ? undefined : handleAction,
@ -47,6 +56,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
return <li ref={ref} return <li ref={ref}
onClick={handleAction} onClick={handleAction}
data-selected={data.selected} data-selected={data.selected}
aria-disabled={data.disabled}
className={ className={
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}> twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
@ -72,12 +82,13 @@ export interface DialogEntry
shortcuts?: Shortcut[]; 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 [open, setOpen] = useState(false);
const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined); const [sourceFocusKey, setSourceFocusKey] = useState<string | undefined>(undefined);
const handleClose = (value: boolean, newSourceFocusKey?: string) => const handleClose = (value: boolean, newSourceFocusKey?: string) =>
{ {
if (data.canClose === false) return;
if (value === open) return; if (value === open) return;
if (value) 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 { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
@ -9,6 +9,8 @@ import MainActions from "./MainActions";
import ActionButton from "./ActionButton"; import ActionButton from "./ActionButton";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import FocusTooltip from "../FocusTooltip"; import FocusTooltip from "../FocusTooltip";
import { Router } from "@/mainview";
import { useBlocker } from "@tanstack/react-router";
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams) 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 { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
...deleteGameMutation({ id: data.id, source: data.source }), ...deleteGameMutation({ id: data.id, source: data.source }),
onSuccess: () => onSuccess: (d, v, r, ctx) =>
{ {
location.reload(); ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source)).then(() => Router.history.back());
console.log("Deleted");
}, },
onError (error) onError (error)
{ {
@ -46,22 +47,35 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
} }
}); });
useBlocker({ shouldBlockFn: () => deleteMutation.isPending });
const contextOptions: DialogEntry[] = []; const contextOptions: DialogEntry[] = [];
if (data.game?.local) if (data.game?.local)
{ {
contextOptions.push({ if (deleteMutation.isPending)
id: 'delete', {
action: () => contextOptions.push({
{ id: 'delete',
deleteMutation.mutate(); icon: <span className="loading loading-spinner loading-lg"></span>,
}, content: "Deleting",
icon: <Trash />, type: 'error'
content: "Delete", });
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"> 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}> <FocusContext value={focusKey}>

View file

@ -7,7 +7,7 @@ import toast from "react-hot-toast";
import { useLocalStorage } from "usehooks-ts"; import { useLocalStorage } from "usehooks-ts";
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { Clock, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react"; 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"; import ActionButton from "./ActionButton";
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) 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') if (e.data.status === 'refresh')
{ {
queryClient.invalidateQueries({ queryKey: ['game', data.id] }); const localId = e.data.localId;
Router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true }); 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') } else if (e.data.status === 'error')
{ {
const errorMessage = getErrorMessage(e.data.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', { 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 = { const commands: DialogEntry = {
id: String(c.id), id: String(c.id),
content: c.label ?? "", content: c.label ?? "",
type: 'primary', type: 'primary',
selected: preferredCommand !== undefined ? preferredCommand === c.id : i === 0,
action (ctx) action (ctx)
{ {
setPreferredCommand(c.id); setPreferredCommand(c.id);

View file

@ -1,8 +1,8 @@
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
import { rommApi, settingsApi } from "../clientApi"; 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 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({ export const allGamesQuery = (filter?: GameListFilterType) => queryOptions({
queryKey: ['games', filter ?? 'all'], queryKey: ['games', filter ?? 'all'],
@ -146,4 +146,12 @@ export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) =>
if (error) throw error; if (error) throw error;
return data; 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;
},
}); });