fix: Issues with launching and installation on the steam deck
This commit is contained in:
parent
dc0f2d150a
commit
ccc5a05ed7
19 changed files with 247 additions and 80 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -67,9 +67,12 @@ function spawnBrowser ()
|
|||
}
|
||||
|
||||
let server = spawnServer();
|
||||
if (!process.env.HEADLESS)
|
||||
{
|
||||
spawnBrowser()?.then(async e =>
|
||||
{
|
||||
console.log("Sending exit Signal to server");
|
||||
await server.stdin.write('shutdown\n');
|
||||
await server.stdin.flush();
|
||||
});
|
||||
}
|
||||
|
|
@ -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 ()
|
||||
{
|
||||
this.backend = new GamepadWindows(index);
|
||||
if (process.platform === "win32")
|
||||
{
|
||||
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?.();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -128,11 +130,23 @@ export async function getValidLaunchCommands (data: {
|
|||
}
|
||||
|
||||
function escapeWindowsArg (arg: string): string
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
@ -147,3 +146,30 @@ export async function simulateProgress (setProgress: (p: number) => void, signal
|
|||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -97,7 +97,10 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise<B
|
|||
// Check bundled
|
||||
if (includeBundled)
|
||||
{
|
||||
const getVerstion = await Bun.file('./bin/chromium/.chromium-version').text();
|
||||
const versionFile = Bun.file('./bin/chromium/.chromium-version');
|
||||
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())
|
||||
{
|
||||
|
|
@ -105,6 +108,8 @@ export async function getBrowserPath (config?: BrowserPriorityConfig): Promise<B
|
|||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 1. Check for currently running browser process
|
||||
if (includeRunning)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,8 +47,20 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
}
|
||||
});
|
||||
|
||||
useBlocker({ shouldBlockFn: () => deleteMutation.isPending });
|
||||
|
||||
const contextOptions: DialogEntry[] = [];
|
||||
if (data.game?.local)
|
||||
{
|
||||
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',
|
||||
|
|
@ -60,8 +73,9 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
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}>
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
@ -147,3 +147,11 @@ export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) =>
|
|||
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;
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue