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": {
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,12 @@ function spawnBrowser ()
|
||||||
}
|
}
|
||||||
|
|
||||||
let server = spawnServer();
|
let server = spawnServer();
|
||||||
spawnBrowser()?.then(async e =>
|
if (!process.env.HEADLESS)
|
||||||
{
|
{
|
||||||
|
spawnBrowser()?.then(async e =>
|
||||||
|
{
|
||||||
console.log("Sending exit Signal to server");
|
console.log("Sending exit Signal to server");
|
||||||
await server.stdin.write('shutdown\n');
|
await server.stdin.write('shutdown\n');
|
||||||
await server.stdin.flush();
|
await server.stdin.flush();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 ()
|
||||||
{
|
{
|
||||||
this.backend = new GamepadWindows(index);
|
if (process.platform === "win32")
|
||||||
|
{
|
||||||
|
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?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -128,11 +130,23 @@ export async function getValidLaunchCommands (data: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeWindowsArg (arg: string): string
|
function escapeWindowsArg (arg: string): string
|
||||||
|
{
|
||||||
|
if (process.platform === 'win32')
|
||||||
{
|
{
|
||||||
return `"${arg
|
return `"${arg
|
||||||
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
|
.replace(/(\\*)"/g, '$1$1\\"') // escape quotes
|
||||||
.replace(/(\\*)$/, '$1$1') // escape trailing backslashes
|
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({});
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
@ -147,3 +146,30 @@ export async function simulateProgress (setProgress: (p: number) => void, signal
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,10 @@ 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');
|
||||||
|
if (await versionFile.exists())
|
||||||
|
{
|
||||||
|
const getVerstion = await versionFile.text();
|
||||||
const binPath = getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch);
|
const binPath = getBundledBinaryPath("./bin/chromium", getVerstion, process.platform, process.arch);
|
||||||
if (await Bun.file(binPath).exists())
|
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
|
// 1. Check for currently running browser process
|
||||||
if (includeRunning)
|
if (includeRunning)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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,8 +47,20 @@ 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)
|
||||||
|
{
|
||||||
|
if (deleteMutation.isPending)
|
||||||
|
{
|
||||||
|
contextOptions.push({
|
||||||
|
id: 'delete',
|
||||||
|
icon: <span className="loading loading-spinner loading-lg"></span>,
|
||||||
|
content: "Deleting",
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
} else
|
||||||
{
|
{
|
||||||
contextOptions.push({
|
contextOptions.push({
|
||||||
id: 'delete',
|
id: 'delete',
|
||||||
|
|
@ -60,8 +73,9 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
||||||
type: 'error'
|
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}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
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 });
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
@ -147,3 +147,11 @@ export const gamesRecommendedBasedOnGameQuery = (source: string, id: string) =>
|
||||||
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue