diff --git a/README.md b/README.md index e3b2c46..26befdf 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Cross-Platform open source Retro gaming frontend designed for handheld and con Focused on building a simple user experience and intuitive UI as a curated community driven experience. > [!WARNING] -> This app is actively in development, it doesn't have most of its major features implemented yet. +> This app is actively in development, it is contantly chaning and improving. > It will have an opinionated design and will be used as an experiment in discovering a good UX. ## Features @@ -13,6 +13,8 @@ Focused on building a simple user experience and intuitive UI as a curated commu - **[ROMM](https://github.com/rommapp/romm)** - download, sync and update roms and platforms. - **[Emulator JS](https://github.com/EmulatorJS/EmulatorJS)** - play your games with emulator js right within the app. Uses RetroArch cores. +- **[RClone](https://github.com/rclone/rclone)** - sync saves between devices or cloud. +- **[UMU](https://github.com/Open-Wine-Components/umu-launcher)** - UMU Launcher for playing windows games on linux without needing steam. (Only used for store games for now) ### Store @@ -32,6 +34,7 @@ Focused on building a simple user experience and intuitive UI as a curated commu - **Automatic Emulator Discovery** - Using the configs of the excellent ES-DE to discover installed emulators and launch games. - Easy fallback configuration with built in file browser. - **Responsive Layout** - Optimized mainly for the steam deck with responsive layout support and dynamic switching of inputs. +- **Cloud/Device Save Sync** - For supported games and emulators. ## Screenshots diff --git a/drizzle/0002_flowery_rocket_raccoon.sql b/drizzle/0002_flowery_rocket_raccoon.sql index 0d8fa7e..5f7942e 100644 --- a/drizzle/0002_flowery_rocket_raccoon.sql +++ b/drizzle/0002_flowery_rocket_raccoon.sql @@ -22,7 +22,7 @@ CREATE TABLE `__new_games` ( FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action ); --> statement-breakpoint -INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system" FROM `games`;--> statement-breakpoint +INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", NULL, "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", NULL, NULL, NULL FROM `games`;--> statement-breakpoint DROP TABLE `games`;--> statement-breakpoint ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint PRAGMA foreign_keys=ON;--> statement-breakpoint diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index e4401bb..f54671c 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -72,6 +72,7 @@ export async function load () console.log("Config Path Located At: ", config.path); console.log("Custom Emulator Paths Located At: ", customEmulators.path); console.log("App Directory is ", process.env.APPDIR); + console.log("Cache Path is ", cachePath); cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); diff --git a/src/bun/api/auth.ts b/src/bun/api/auth.ts index a0740bc..47ea019 100644 --- a/src/bun/api/auth.ts +++ b/src/bun/api/auth.ts @@ -46,67 +46,7 @@ export default new Elysia() return status(res.status, res.statusText); }) - .get('/login/twitch', async () => - { - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (!access_token) - { - return status('Not Found', "Not Logged In"); - } - - const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } }); - if (res.ok) - { - return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; - } - - if (!process.env.TWITCH_CLIENT_ID) - { - return status("Not Found", "Twitch Client ID not set"); - } - - const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" }); - if (!refresh_token) - { - return status("Not Found", "Refresh Token Not Found"); - } - - // refresh token - const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', { - method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - client_id: process.env.TWITCH_CLIENT_ID, - access_token, - grant_type: "refresh_token", - refresh_token - }) - }); - - if (refreshResponse.ok) - { - const data: { - access_token: string, - refresh_token: string, - token_type: string; - expires_in: number; - } = await refreshResponse.json(); - - await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token }); - await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); - await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); - - await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); - - events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' }); - - const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } }); - if (res.ok) - { - return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; - } - } - - return status(400, res.statusText); - }) + .get('/login/twitch', checkLoginAndRefreshTwitch) .post('/login/romm/qr', async () => { if (taskQueue.hasActiveOfType(LoginJob)) @@ -123,47 +63,7 @@ export default new Elysia() return data.data as UserSchema; }) .post('/login/romm', async ({ body }) => tryLoginAndSave(body), { body: z.object({ host: z.url(), username: z.string(), password: z.string() }) }) - .get('/login/romm', async () => - { - const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); - if (!access_token) - { - return { hasLogin: false }; - } - - const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" }); - if (expires_in) - { - const date = new Date(expires_in); - if (date > new Date()) - { - return { hasLogin: true }; - } - } - - const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" }); - if (!refresh_token) - { - return { hasLogin: false }; - } - - const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } }); - - if (refreshResponse.response.ok && refreshResponse.data) - { - await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token }); - if (refreshResponse.data.refresh_token) - await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token }); - await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() }); - - await plugins.hooks.auth.loginComplete.promise({ service: 'romm' }); - - events.emit('notification', { message: "Romm Refresh Successful", type: 'success' }); - return { hasLogin: true }; - } - - return status(refreshResponse.response.status, refreshResponse.response.statusText) as any; - }, + .get('/login/romm', checkLoginAndRefreshRomm, { response: z.object({ hasLogin: z.boolean() }) }) .post('/logout/romm', async () => { @@ -174,6 +74,109 @@ export default new Elysia() }, { response: z.any() }); +export async function checkLoginAndRefreshTwitch () +{ + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (!access_token) + { + return status('Not Found', "Not Logged In"); + } + + const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${access_token}` } }); + if (res.ok) + { + return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; + } + + if (!process.env.TWITCH_CLIENT_ID) + { + return status("Not Found", "Twitch Client ID not set"); + } + + const refresh_token = await secrets.get({ service: 'gamflow_twitch', name: "refresh_token" }); + if (!refresh_token) + { + return status("Not Found", "Refresh Token Not Found"); + } + + // refresh token + const refreshResponse = await fetch('https://id.twitch.tv/oauth2/token', { + method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ + client_id: process.env.TWITCH_CLIENT_ID, + access_token, + grant_type: "refresh_token", + refresh_token + }) + }); + + if (refreshResponse.ok) + { + const data: { + access_token: string, + refresh_token: string, + token_type: string; + expires_in: number; + } = await refreshResponse.json(); + + await secrets.set({ service: 'gamflow_twitch', name: 'access_token', value: data.access_token }); + await secrets.set({ service: 'gamflow_twitch', name: 'refresh_token', value: data.refresh_token }); + await secrets.set({ service: 'gamflow_twitch', name: 'expires_in', value: new Date(new Date().getTime() + data.expires_in).toString() }); + + await plugins.hooks.auth.loginComplete.promise({ service: 'twitch' }); + + events.emit('notification', { message: "Twitch Refresh Successful", type: 'success' }); + + const res = await fetch('https://id.twitch.tv/oauth2/validate', { headers: { Authorization: `OAuth ${data.access_token}` } }); + if (res.ok) + { + return await res.json() as { login: string, expires_in: number; client_id: string, user_id: string; }; + } + } + + return status(400, res.statusText); +} + +export async function checkLoginAndRefreshRomm () +{ + const access_token = await secrets.get({ service: 'gameflow', name: 'romm_access_token' }); + if (!access_token) + { + return { hasLogin: false }; + } + + const expires_in = await secrets.get({ service: 'gameflow', name: "romm_expires_in" }); + if (expires_in) + { + const date = new Date(expires_in); + if (date > new Date()) + { + return { hasLogin: true }; + } + } + + const refresh_token = await secrets.get({ service: 'gameflow', name: "romm_refresh_token" }); + if (!refresh_token) + { + return { hasLogin: false }; + } + + const refreshResponse = await tokenApiTokenPost({ body: { grant_type: "refresh_token", refresh_token: refresh_token } }); + + if (refreshResponse.response.ok && refreshResponse.data) + { + await secrets.set({ service: 'gameflow', name: 'romm_access_token', value: refreshResponse.data.access_token }); + if (refreshResponse.data.refresh_token) + await secrets.set({ service: 'gameflow', name: 'romm_refresh_token', value: refreshResponse.data.refresh_token }); + await secrets.set({ service: 'gameflow', name: 'romm_expires_in', value: new Date(new Date().getTime() + refreshResponse.data.expires * 1000).toString() }); + + await plugins.hooks.auth.loginComplete.promise({ service: 'romm' }); + + events.emit('notification', { message: "Romm Refresh Successful", type: 'success' }); + return { hasLogin: true }; + } + + return status(refreshResponse.response.status, refreshResponse.response.statusText) as any; +} export async function tryLoginAndSave ({ host, username, password }: { host: string, username: string, password: string; }) { diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index 6aa465a..ff84b4d 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { cache } from "./app"; import cacheSchema from "@schema/cache"; import { GithubReleaseSchema } from "@/shared/constants"; +import PQueue from "p-queue"; export const CACHE_KEYS = { ROM_PLATFORMS: 'rom-platforms', @@ -9,6 +10,8 @@ export const CACHE_KEYS = { STORE_GAME_MANIFEST: 'store-game-manifest' } as const; +export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true }); + export async function getOrCached (key: string, getter: () => Promise, options?: { expireMs?: number; }): Promise { const cached = await cache.query.item_cache.findFirst({ where: eq(cacheSchema.item_cache.key, key) }); @@ -37,10 +40,10 @@ export async function getOrCached (key: string, getter: () => Promise, opt export async function getOrCachedGithubRelease (path: string) { - return getOrCached(`github-release-${path}`, async () => + return getOrCached(`github-release-${path}`, async () => githubRequestQueue.add(async () => { const response = await fetch(`https://api.github.com/repos/${path}/releases/latest`, { method: "GET" }); if (!response.ok) throw new Error(response.statusText); return GithubReleaseSchema.parseAsync(await response.json()); - }); + }), { expireMs: 1000 * 60 * 60 }); } \ No newline at end of file diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index aa715ca..fb94e71 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -124,6 +124,12 @@ export class GameHooks platformSlug?: string; }; }]>(["ctx"]); + postInstall = new AsyncSeriesHook<[ctx: { + source: string, + id: string; + files: string[]; + info: DownloadInfo; + }]>(['ctx']); fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']); fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']); diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 74e13d2..0da918e 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -11,6 +11,7 @@ import { ensureDir, move } from "fs-extra"; import { simulateProgress } from "@/bun/utils"; import { path7za } from "7zip-bin"; import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService"; +import { $ } from "bun"; type EmulatorDownloadStates = "download" | "extract"; @@ -61,7 +62,7 @@ export class EmulatorDownloadJob implements IJob + if (destinationPath.endsWith('.tar')) { - const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true }); - seven.on('progress', p => context.setProgress(p.percent, "extract")); - seven.on('error', e => reject(e)); - seven.on('end', () => resolve(true)); - }); - await fs.rm(destinationPath, { recursive: true }); + context.setProgress(0, "extract"); + await ensureDir(emulatorsFolder); + await $`tar -xf ${destinationPath} -C ${emulatorsFolder}`; + await fs.rm(destinationPath, { recursive: true }); + } else + { + await new Promise((resolve, reject) => + { + const seven = Seven.extractFull(destinationPath, emulatorsFolder, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true, noRootDuplication: true }); + seven.on('progress', p => context.setProgress(p.percent, "extract")); + seven.on('error', e => reject(e)); + seven.on('end', () => resolve(true)); + }); + await fs.rm(destinationPath, { recursive: true }); + } // check if 1 root folder we need to get rid of const contents = await fs.readdir(emulatorsFolder); @@ -106,15 +116,18 @@ export class EmulatorDownloadJob implements IJob e.type === 'store')?.binPath ?? emulatorsFolder, info, update: this.isUpdate }); - - await Bun.write(`${emulatorsFolder}.json`, JSON.stringify(info, null, 3)); } } diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 166af46..9a2b8b8 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -59,6 +59,7 @@ export class InstallJob implements IJob if (!info) throw new Error(`Could not find downloader for source ${this.source}`); const files = await checkFiles(info.files, !!info.extract_path); + const finalFiles: string[] = []; if (this.config?.dryRun !== true) { @@ -84,6 +85,7 @@ export class InstallJob implements IJob { return; } + if (info.extract_path && downloadedFiles) { let progress = 0; @@ -139,6 +141,7 @@ export class InstallJob implements IJob if (filePath.endsWith('.zip')) { cx.setProgress(0, "extract"); + console.error(e); console.warn("Could not extract", filePath, "with 7zip trying zip extractor"); await ensureDir(extractPath); const zip = new StreamZip.async({ file: filePath }); @@ -175,6 +178,12 @@ export class InstallJob implements IJob await move(tmpGameFolder, extractPath, { overwrite: true }); } } + + finalFiles.push(extractPath); + + } else + { + finalFiles.push(...downloadedFiles); } } @@ -323,6 +332,7 @@ export class InstallJob implements IJob await simulateProgress(p => cx.setProgress(p, "download"), cx.abortSignal); } + await plugins.hooks.games.postInstall.promise({ source: this.source, id: this.gameId, files: finalFiles, info }); events.emit('notification', { message: `${info.name}: Installed`, type: 'success', duration: 8000 }); } diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index 1a430e5..139d8af 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -5,9 +5,9 @@ import { db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; -import { watch } from "node:fs"; import fs from "node:fs/promises"; import { updateLocalLastPlayed } from "../games/services/statusService"; +import { getErrorMessage } from "@/bun/utils"; export class LaunchGameJob implements IJob, string> { @@ -42,15 +42,24 @@ export class LaunchGameJob implements IJob console.error(e)); + await new Promise(async (resolve) => + { + await plugins.hooks.games.postPlay.promise( + { + source, + id, + command: this.validCommand, + changedSaveFiles: Array.from(this.changedSaveFiles.values()), + validChangedSaveFiles: {}, + gameInfo + }).catch(e => + { + console.error(e); + events.emit('notification', { message: getErrorMessage(e), type: 'error' }); + }).then(() => resolve(false)); + const timeoutHandler = () => resolve(false); + setTimeout(timeoutHandler, 5000); + }); } prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; }) @@ -118,31 +127,58 @@ export class LaunchGameJob implements IJob reject(e)); - // ES-DE commands require shell execution. Some emulators fail otherwise. - const spawnGame = spawn(this.validCommand.command, { - shell: this.validCommand.shell ?? true, - cwd: this.validCommand.startDir, - signal: context.abortSignal, - env: { - ...process.env, - ...this.validCommand.env - }, - }); - - context.setProgress(0, "playing"); - - spawnGame.stdout.on('data', data => console.log(data)); - spawnGame.on('close', (code) => + if (Array.isArray(this.validCommand.command)) { - resolve(code); - }); - spawnGame.on('error', e => - { - console.error(e); - resolve(1); - }); + const bunGame = Bun.spawn(this.validCommand.command, { + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + ...process.env, + ...this.validCommand.env + } + }); + + context.setProgress(0, "playing"); + + bunGame.exited.then(e => + { + resolve(true); + }).catch(e => + { + console.error(e); + reject(e); + }); + + game = bunGame; + } else + { + // ES-DE commands require shell execution. Some emulators fail otherwise. + const spawnGame = spawn(this.validCommand.command, { + shell: this.validCommand.shell ?? true, + cwd: this.validCommand.startDir, + signal: context.abortSignal, + env: { + ...process.env, + ...this.validCommand.env + }, + }); + + context.setProgress(0, "playing"); + + spawnGame.stdout.on('data', data => console.log(data)); + spawnGame.on('close', (code) => + { + resolve(code); + }); + spawnGame.on('error', e => + { + console.error(e); + resolve(1); + }); + + game = spawnGame; + } - game = spawnGame; } else if (this.validCommand.metadata.emulatorBin) { @@ -151,7 +187,6 @@ export class LaunchGameJob implements IJob; @@ -75,8 +77,8 @@ export default class RcloneIntegration implements PluginType await ensureDir(toolsPath); const binaryMap: Record = { win32: '**/rclone.exe', - linux: '**/rclone', - darwin: '**/rclone' + linux: 'rclone-*/rclone', + darwin: 'rclone-*/rclone' }; const existingRclones = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath })); if (existingRclones[0]) @@ -102,7 +104,7 @@ export default class RcloneIntegration implements PluginType await ensureDir(toolsPath); await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); - const dests = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + const dests = await Array.fromAsync(fs.glob(binaryMap[process.platform], { cwd: toolsPath })); if (dests[0]) { this.rclonePath = path.join(toolsPath, dests[0]); @@ -218,7 +220,7 @@ export default class RcloneIntegration implements PluginType ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) => { - if (source !== 'store' || !this.rclonePath || !saveFolderSlots) return; + if (source !== 'store' || !this.rclonePath || !saveFolderSlots || !ctx.config.get('importSaves')) return; for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) { @@ -250,8 +252,7 @@ export default class RcloneIntegration implements PluginType UseJSONLog: true, LogLevel: "DEBUG", HumanReadable: true, - Progress: true, - DryRun: true + Progress: true } }); console.log(data); @@ -261,7 +262,7 @@ export default class RcloneIntegration implements PluginType ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) => { - if (source !== 'store' || !this.rclonePath) return; + if (source !== 'store' || !this.rclonePath || !ctx.config.get('exportSaves')) return; console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts index 78d28b3..ba7bfed 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -3,6 +3,7 @@ import desc from './package.json'; import secrets from "@/bun/api/secrets"; import PQueue from 'p-queue'; import * as igdb from '@phalcode/ts-igdb-client'; +import { checkLoginAndRefreshTwitch } from "@/bun/api/auth"; export default class IgdbIntegration implements PluginType { @@ -39,6 +40,8 @@ export default class IgdbIntegration implements PluginType async load (ctx: PluginLoadingContextType) { + await checkLoginAndRefreshTwitch(); + ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) => { if (!process.env.TWITCH_CLIENT_ID) return; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index c0d754e..ccc0c29 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -13,6 +13,7 @@ import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; import { validateGameSource } from "@/bun/api/games/services/statusService"; import z from "zod"; +import { checkLoginAndRefreshRomm } from "@/bun/api/auth"; const SettingsSchema = z.object({ savesSync: z.boolean().default(false).describe("Experimental save sync support") @@ -143,6 +144,8 @@ export default class RommIntegration implements PluginType async load (ctx: PluginLoadingContextType) { this.isSteamDeck = isSteamDeckGameMode(); + ctx.setProgress(0, "Logging Into Romm"); + await checkLoginAndRefreshRomm(); await this.updateClient(); ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => @@ -150,7 +153,6 @@ export default class RommIntegration implements PluginType if (!await this.checkRemote()) return; if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { - const rommGames = await getRomsApiRomsGet({ query: { platform_ids: query.platform_id ? [query.platform_id] : undefined, diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts index 852284a..1d92b59 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from "node:path"; import * as appSchema from '@schema/app'; import * as emulatorSchema from '@schema/emulators'; -import { db, emulatorsDb, plugins } from "@/bun/api/app"; +import { config, db, emulatorsDb, plugins } from "@/bun/api/app"; import { and, eq } from "drizzle-orm"; import { getOrCached } from "@/bun/api/cache"; import { Glob } from "bun"; @@ -318,4 +318,47 @@ export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackag // this should only happen if download info is missing maybe manually deleted or wasn't saved. return undefined; +} + +export async function buildLaunchCommand (ctx: { gamePath: string; systemSlug: string; mainGlob?: string | null; }): Promise +{ + if (ctx.systemSlug !== 'win' && ctx.systemSlug !== 'linux' && ctx.systemSlug !== 'mac') return; + const downloadPath = config.get('downloadPath'); + const gamePathAbsolute = path.join(downloadPath, ctx.gamePath); + if (!(await fs.exists(gamePathAbsolute))) return; + const gamePathStat = await fs.stat(gamePathAbsolute); + + if (gamePathStat.isDirectory()) + { + let mainGlob = ctx.mainGlob; + if (!mainGlob && ctx.systemSlug === 'win') mainGlob = '**/*.exe'; + if (!mainGlob) return; + const fileGlob = new Glob(mainGlob); + for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, ctx.gamePath) })) + { + return { + startDir: path.join(downloadPath, ctx.gamePath, path.dirname(file)), + command: [`./${path.basename(file)}`], + id: `store-${process.platform}`, + shell: false, + valid: true, + metadata: { + romPath: path.join(downloadPath, ctx.gamePath, file) + } + }; + } + + } else + { + return { + startDir: path.join(downloadPath, path.dirname(ctx.gamePath)), + command: [`./${path.basename(ctx.gamePath)}`], + id: `store-${process.platform}`, + valid: true, + shell: false, + metadata: { + romPath: path.join(downloadPath, ctx.gamePath), + } + }; + } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index 59e3b26..c57330e 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -10,11 +10,24 @@ import { config, emulatorsDb, taskQueue } from "@/bun/api/app"; import fs from "node:fs/promises"; import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import UpdateStoreJob from "@/bun/api/jobs/update-store"; -import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService"; -import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; +import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; +import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; +import { path7za } from "7zip-bin"; export default class RommIntegration implements PluginType { + eventsNames = [{ id: 'updateStore', title: "Update Store", description: "Update the Store Manifest", action: "Update" }]; + + async onEvent (e: string) + { + switch (e) + { + case 'updateStore': + await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + return { reload: true }; + } + } + async setup (ctx: PluginLoadingContextType) { console.log("Store Directory is ", getStoreFolder()); @@ -126,52 +139,52 @@ export default class RommIntegration implements PluginType saves?.forEach(([key, val]) => validChangedSaveFiles[key] = val); }); + ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ emulator, sources }) => + { + const emulatorPackage = await getStoreEmulatorPackage(emulator); + if (!emulatorPackage) return undefined; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); + if (!storeDownloadInfo) return; + const emulatorPath = getEmulatorPath(emulator); + if (!await fs.exists(emulatorPath)) return; + const validDownload = emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].find(d => d.type === storeDownloadInfo?.type); + if (!validDownload || !validDownload.bin) return; + const glob = new Glob(validDownload.bin); + const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath })); + if (files.length > 0) + { + sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' }); + } + }); + + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: 'UMU' }, async ({ path: emulatorPath }) => + { + const pathStat = await fs.stat(emulatorPath); + if (pathStat.isFile()) + { + await fs.chmod(emulatorPath, 0o755); + } + }); + + ctx.hooks.games.postInstall.tapPromise(desc.name, async ({ source, id, files, info }) => + { + if (source !== 'store') return; + if (files.length === 1) + { + const command = await buildLaunchCommand({ gamePath: files[0], systemSlug: info.system_slug, mainGlob: info.main_glob }); + if (command && command.metadata.romPath) + { + await fs.chmod(command.metadata.romPath, 0o755); + } + } + }); + ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) => { if (source !== 'store' || !gamePath) return; - const downloadPath = config.get('downloadPath'); - const gamePathAbsolute = path.join(downloadPath, gamePath); - if (!(await fs.exists(gamePathAbsolute))) return; - const gamePathStat = await fs.stat(gamePathAbsolute); - - if (gamePathStat.isDirectory()) - { - if (!mainGlob && systemSlug !== 'win') return; - const fileGlob = new Glob(mainGlob ?? '**/*.exe'); - for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) })) - { - return [{ - startDir: path.join(downloadPath, gamePath, dirname(file)), - command: `./${basename(file)}`, - id: `store-${process.platform}`, - shell: false, - valid: true, - env: { - XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') - }, - metadata: { - romPath: path.join(downloadPath, gamePath, file) - } - }]; - } - - } else - { - return [{ - startDir: path.join(downloadPath, dirname(gamePath)), - command: `./${basename(gamePath)}`, - env: { - XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') - }, - id: `store-${process.platform}`, - valid: true, - shell: false, - metadata: { - romPath: path.join(downloadPath, gamePath) - } - }]; - } - + const command = await buildLaunchCommand({ gamePath, systemSlug, mainGlob }); + if (!command) return; + return [command]; }); ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts index 6a2dedc..a898036 100644 --- a/src/bun/api/plugins/plugins.ts +++ b/src/bun/api/plugins/plugins.ts @@ -19,7 +19,7 @@ export default new Elysia({ prefix: '/plugins' }) canDisable: p.description.canDisable ?? true, icon: p.description.icon, category: p.description.category, - hasSettings: !!p.config + hasSettings: !!p.config || !!p.plugin.eventsNames }; return plugin; }); diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index 17ee6ec..2e0d675 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -1,6 +1,6 @@ import { EmulatorPackageSchema, EmulatorPackageType, GithubManifestSchema, StoreGameSchema } from "@/shared/constants"; import { CACHE_KEYS, getOrCached } from "../../cache"; -import { and, eq } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { config, emulatorsDb } from '../../app'; import path from "node:path"; import fs from 'node:fs/promises'; @@ -46,10 +46,10 @@ export async function buildStoreFrontendEmulatorSystems (emulator: EmulatorPacka const systems = await Promise.all(emulator.systems.map(async system => { const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ - where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)) + where: or(and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.system, system)), and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, system))) }); - const esSystem = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.emulators.name, system), columns: { fullname: true } }); + const esSystem = await emulatorsDb.query.systems.findFirst({ where: or(eq(emulatorSchema.emulators.name, system), eq(emulatorSchema.emulators.name, rommSystem?.system ?? '')), columns: { fullname: true } }); let icon: string = `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`; diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index e70da91..1c4323a 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -12,7 +12,7 @@ import { CACHE_KEYS, getOrCached } from "../cache"; import { getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; import { BiosDownloadJob } from "../jobs/bios-download-job"; -import { findEmulatorPluginIntegration } from "./services/emulatorsService"; +import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService"; export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -148,13 +148,22 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .delete('/emulator/:id', async ({ params: { id } }) => { - const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); + const storeEmulatorFolder = getEmulatorPath(id); + const existingPackagePath = `${storeEmulatorFolder}.json`; + let hadDelete = false; + if (await fs.exists(existingPackagePath)) + { + await fs.rm(existingPackagePath); + hadDelete = true; + } + if (await fs.exists(storeEmulatorFolder)) { fs.rm(storeEmulatorFolder, { recursive: true }); - return status("OK"); + hadDelete = true; } - return status("Not Found"); + + return hadDelete ? status("OK") : status("Not Found"); }) .post('/download/bios/:id', async ({ params: { id } }) => { diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 5292b85..927c00d 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -15,18 +15,22 @@ import { getStoreFolder } from "./store/services/gamesService"; import ReloadPluginsJob from "./jobs/reload-plugins-job"; import { semver } from "bun"; import packageDef from '~/package.json'; +import { getOrCached, githubRequestQueue } from "./cache"; async function checkUpdate () { - const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); - if (latest.ok) + return getOrCached('check-for-update', async () => githubRequestQueue.add(async () => { - const data = await latest.json(); - const hasUpdate = semver.order(data.tag_name, packageDef.version); - return hasUpdate; - } + const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); + if (latest.ok) + { + const data = await latest.json(); + const hasUpdate = semver.order(data.tag_name, packageDef.version); + return hasUpdate; + } - return 0; + return 0; + }), { expireMs: 1000 * 60 * 60 }); } export const system = new Elysia({ prefix: '/api/system' }) diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index c87a6ef..56bf90c 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -36,7 +36,10 @@ export const PluginSchema = z.object({ description: z.string().optional(), action: z.string() }).array().optional(), - onEvent: z.function().input([z.string()]).output(z.any()).optional() + onEvent: z.function().input([z.string()]).output(z.object({ + openTab: z.string().optional(), + reload: z.boolean().optional() + }).or(z.record(z.string(), z.any()))).optional() }); export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { @@ -55,6 +58,6 @@ export const ActiveGameSchema = z.object({ source: z.string().optional(), sourceId: z.string().optional(), name: z.string(), - command: z.object({ command: z.string(), startDir: z.string().optional() }) + command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() }) }); export type ActiveGameType = z.infer; \ No newline at end of file diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index ca2e27d..5de871d 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -63,7 +63,7 @@ export function CardList (data: { onSelectGame?: (id: string) => void; focus?: string; className?: string; - finalElement?: JSX.Element; + finalElement?: JSX.Element | JSX.Element[]; saveChildFocus?: 'session' | 'local'; } & FocusParams) { diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 689900d..6788952 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry } from "./ContextDialog"; import { systemApi } from "../scripts/clientApi"; -import { useContext, useRef, useState } from "react"; +import { FocusEventHandler, useContext, useRef, useState } from "react"; import path from "pathe"; import { Check, File, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; @@ -15,6 +15,7 @@ import toast from "react-hot-toast"; import { FilePickerContext } from "../scripts/contexts"; import useActiveControl from "../scripts/gamepads"; import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system"; +import { showKeyboardHandler } from "../scripts/utils"; function List (data: { id: string, @@ -87,15 +88,16 @@ function List (data: { function NewFolderInput (data: { id: string, name: string | undefined, setName: (name: string) => void; className?: string; }) { const inputRef = useRef(null); + const { control } = useActiveControl(); const { ref, focused, focusSelf } = useFocusable({ focusKey: data.id, onEnterPress: () => inputRef.current?.focus(), onBlur: () => inputRef.current?.blur(), }); - const handleFocus = () => + const handleFocus: FocusEventHandler = (e) => { focusSelf(); - systemApi.api.system.show_keyboard.post(); + showKeyboardHandler(control as any, e.target); }; return
void; focus?: string; className?: string; - finalElement?: JSX.Element; + finalElement?: JSX.Element | JSX.Element[]; + emptyElement?: JSX.Element | JSX.Element[]; saveChildFocus?: "session" | "local"; } @@ -52,6 +53,25 @@ export function GameList (data: GameListParams) navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); }; + const finalElement: JSX.Element[] = []; + if (!games.isFetching && !!games.data && games.data.games.length <= 0) + { + if (Array.isArray(data.emptyElement)) + { + finalElement.push(...data.emptyElement); + } else if (data.emptyElement) + { + finalElement.push(data.emptyElement); + } + } + if (Array.isArray(data.finalElement)) + { + finalElement.push(...data.finalElement); + } else if (data.finalElement) + { + finalElement.push(data.finalElement); + } + return ( <> 0 ?
{systemContext.wifiConnections.map(w => { - const className = "w-6 h-6"; + const className = "w-10 h-10"; let icon = ; if (w.signalLevel >= -60) icon = ; @@ -164,7 +164,7 @@ function WiFiStatus () else if (w.signalLevel >= -90) icon = ; - return
+ return
{icon}
; })} diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx index 3845987..50befd9 100644 --- a/src/mainview/components/HeaderSearchField.tsx +++ b/src/mainview/components/HeaderSearchField.tsx @@ -1,10 +1,13 @@ import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { Ref, RefObject, useEffect, useRef, useState } from "react"; +import { FocusEventHandler, Ref, RefObject, useEffect, useRef, useState } from "react"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { oneShot } from "../scripts/audio/audio"; import { Search } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useEventListener } from "usehooks-ts"; +import { systemApi } from "../scripts/clientApi"; +import { showKeyboardHandler } from "../scripts/utils"; +import useActiveControl from "../scripts/gamepads"; function SearchInput (data: { id: string; @@ -16,6 +19,7 @@ function SearchInput (data: { onSubmit: (search: string | undefined) => void; } & FocusParams) { + const { control } = useActiveControl(); const { ref, focusKey } = useFocusable({ onBlur: () => inputRef.current?.blur(), onFocus: (l, p, d) => @@ -59,6 +63,8 @@ function SearchInput (data: { data.onSubmit?.(undefined); }, inputRef as any); + const handlInputFocus: FocusEventHandler = e => showKeyboardHandler(control as any, e.target); + return
; + }} className='flex data-[hidden=true]:invisible bg-base-100 game-card focusable focusable-accent focusable-hover text-2xl justify-center items-center cursor-pointer' data-hidden={data.hidden} onClick={e => handleAction(e.nativeEvent)} id='load-more-btn'>{data.isFetching ? : "Load More"}
; } \ No newline at end of file diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 08a6261..332d659 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -6,6 +6,8 @@ import { systemApi } from "../../scripts/clientApi"; import { CheckIcon, X } from "lucide-react"; import { oneShot } from "@/mainview/scripts/audio/audio"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; +import { showKeyboardHandler } from "@/mainview/scripts/utils"; +import useActiveControl from "@/mainview/scripts/gamepads"; export function OptionInput (data: { name: string; @@ -35,6 +37,7 @@ export function OptionInput (data: { } oneShot('click'); }; + const { control } = useActiveControl(); const [inputFocused, setInputFocused] = useState(false); const inputRef = useRef(null); const { ref, focusKey } = useFocusable({ @@ -99,20 +102,11 @@ export function OptionInput (data: { return shortcuts; }, [inputFocused, data.type]); - const handleInputFocus = () => + const handleInputFocus: FocusEventHandler = (e) => { option.focus(); setInputFocused(true); - if (inputRef.current) - { - var rect = inputRef.current?.getBoundingClientRect(); - systemApi.api.system.show_keyboard.post({ - XPosition: rect.x, - YPosition: rect.y, - Width: rect.width, - Height: rect.height - }); - } + showKeyboardHandler(control as any, e.target); }; const handleInputBlur = (e: any) => diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 12d7411..e2e6844 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -10,6 +10,9 @@ import OctagonAlert, Maximize, Store, + LayoutGrid, + PlusCircle, + Plus, } from "lucide-react"; import { @@ -39,7 +42,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/ import z from "zod"; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; -import { mobileCheck, useDragScroll } from "../scripts/utils"; +import { mobileCheck, scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; @@ -48,6 +51,7 @@ import { oneShot } from "../scripts/audio/audio"; import { FloatingShortcuts } from "../components/Shortcuts"; import SelectMenu from "../components/SelectMenu"; import HeaderSearchField from "../components/HeaderSearchField"; +import CardElement from "../components/CardElement"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, @@ -91,6 +95,35 @@ function HomeListError (data: { focused: boolean; })
; } +function Preview (data: { index: number; children?: any; }) +{ + const isMobile = mobileCheck(); + return
+ {data.children} +
; +} + +function GetStoreGamesCard () +{ + const router = useRouter(); + const handleNavigate = () => + { + router.navigate({ to: '/store/tab/games' }); + }; + return ]} onAction={handleNavigate} title="Gameflow Store" subtitle="Get Free Games" preview={} focusKey='store-games-btn' index={0} id="store-games-btn" />; +} + function ShowAllGamesCard () { const router = useRouter(); @@ -98,8 +131,7 @@ function ShowAllGamesCard () { router.navigate({ to: '/games' }); }; - const { ref } = useFocusable({ focusKey: 'all-games-btn', onEnterPress: handleNavigate }); - return
All Games
; + return } focusKey='all-games-btn' index={0} id="all-games-btn" />; } function HomeList (data: { @@ -165,7 +197,8 @@ function HomeList (data: { id="games-list" setBackground={bg.setBackground} filters={{ limit: 12, orderBy: 'activity' }} - finalElement={} + finalElement={[, ]} + emptyElement={[]} /> ; diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index bba950b..4254395 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -10,7 +10,8 @@ import { useRef } from 'react'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, staticData: { - enterSound: 'launch' + enterSound: 'launch', + missNavSound: false }, }); diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index a2b87b9..0e63a80 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -24,7 +24,7 @@ import { useJobStatus } from "@/mainview/scripts/utils"; import { useInterval } from "usehooks-ts"; import { TwitchIcon } from "@/mainview/scripts/brandIcons"; import { twitchLoginMutation, twitchLoginVerificationQuery, twitchLogoutMutation } from "@queries/settings"; -import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery } from "@queries/romm"; +import { rommGetOptionsQuery, rommLoggedInQuery, rommHostnameQuery, rommLoginMutation, rommLogoutMutation, rommQrLoginMutation, rommUsernameQuery, rommUserQuery, invalidateLogin } from "@queries/romm"; import { systemApi } from "@/mainview/scripts/clientApi"; export const Route = createFileRoute("/settings/accounts")({ @@ -59,10 +59,7 @@ function TwitchLogin () { const loginStatus = useQuery(twitchLoginVerificationQuery); - const loginMutation = useMutation({ - ...twitchLoginMutation, - onSuccess: () => loginStatus.refetch() - }); + const loginMutation = useMutation(twitchLoginMutation); const logoutMutation = useMutation({ ...twitchLogoutMutation, onSuccess: () => loginStatus.refetch() }); @@ -100,8 +97,8 @@ function LoginControls (data: {}) ...rommLogoutMutation, onSuccess: async (d, v, r, c) => { - user.refetch(); - await c.client.invalidateQueries({ queryKey: ["romm", "auth"] }); + await user.refetch(); + await invalidateLogin(c.client); await router.navigate({ replace: true }); } }); diff --git a/src/mainview/routes/settings/plugin.$source.tsx b/src/mainview/routes/settings/plugin.$source.tsx index ecd46af..78e0623 100644 --- a/src/mainview/routes/settings/plugin.$source.tsx +++ b/src/mainview/routes/settings/plugin.$source.tsx @@ -42,7 +42,7 @@ function PluginAction (data: { id: string, title: string | undefined, descriptio
{data.title ?? data.id}
{data.description}
}> - + ; } diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index d402367..febe997 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -71,6 +71,7 @@ function RouteComponent ()
diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 6b185c3..7ebf4db 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,6 +1,6 @@ import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; import { rommApi, settingsApi } from "../clientApi"; -import { InvalidateQueryFilters, mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; +import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; @@ -23,6 +23,20 @@ export const gameQuery = (source: string, id: string) => queryOptions({ }, }); export const rommLogoutMutation = mutationOptions({ mutationKey: ["romm", "auth", "logout"], mutationFn: () => rommApi.api.romm.logout.romm.post() }); +export const invalidateLogin = (client: QueryClient) => +{ + return client.invalidateQueries({ + predicate (query) + { + return query.queryKey.includes('auth') + || query.queryKey.includes('games') + || query.queryKey.includes('game') + || query.queryKey.includes('platform') + || query.queryKey.includes('platforms') + || query.queryKey.includes('collections'); + }, + }); +}; export const rommQrLoginMutation = mutationOptions({ mutationKey: ['login', 'qr', 'cancel'], mutationFn: async () => @@ -30,7 +44,11 @@ export const rommQrLoginMutation = mutationOptions({ const { data, error } = await rommApi.api.romm.login.romm.qr.post(); if (error) throw error; return data; - } + }, + onSuccess: (d, v, r, c) => + { + invalidateLogin(c.client); + }, }); export const rommLoginMutation = mutationOptions({ mutationKey: ["romm", "login"], @@ -41,7 +59,7 @@ export const rommLoginMutation = mutationOptions({ }, onSuccess: (d, v, r, c) => { - c.client.invalidateQueries({ queryKey: ['romm', 'auth'] }); + invalidateLogin(c.client); }, onError: (e) => { diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts index 5b99ace..e0f605e 100644 --- a/src/mainview/scripts/queries/settings.ts +++ b/src/mainview/scripts/queries/settings.ts @@ -2,6 +2,7 @@ import { mutationOptions, queryOptions } from "@tanstack/react-query"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; import { rommApi, settingsApi } from "../clientApi"; +import { invalidateLogin } from "./romm"; export const changeDownloadsMutation = mutationOptions({ mutationKey: ["setting", "downloads"], @@ -29,21 +30,25 @@ export const autoEmulatorsQuery = queryOptions({ } }); export const twitchLogoutMutation = mutationOptions({ - mutationKey: ['twitch', 'logout'], + mutationKey: ['twitch', 'auth', 'logout'], mutationFn: () => { return rommApi.api.romm.logout.twitch.post(); } }); export const twitchLoginMutation = mutationOptions({ - mutationKey: ['twitch', 'login'], + mutationKey: ['twitch', 'auth', 'login'], mutationFn: (openInBrowser: boolean) => { return rommApi.api.romm.login.twitch.post({ openInBrowser }); - } + }, + onSuccess (data, variables, onMutateResult, context) + { + invalidateLogin(context.client); + }, }); export const twitchLoginVerificationQuery = queryOptions({ - queryKey: ['twitch', 'login', 'status'], + queryKey: ['twitch', 'login', 'status', 'auth'], retry (failureCount, error) { if ((error as any).status === 404) diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 428be6e..9164617 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,7 +1,7 @@ import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; -import { DependencyList, RefObject, useEffect, useRef, useState } from "react"; +import { DependencyList, FocusEventHandler, RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; -import { jobsApi } from "./clientApi"; +import { jobsApi, systemApi } from "./clientApi"; import { JobsAPIType } from "@/bun/api/rpc"; import { AnyRouter, useRouter } from "@tanstack/react-router"; import { soundMap } from "./audio/audioConstants"; @@ -368,4 +368,18 @@ export function useOnNavigateBack (callback: (state: { sound?: keyof typeof soun return unsub; }, [router]); -} \ No newline at end of file +} + +export function showKeyboardHandler (activeControl: string, node?: HTMLInputElement) +{ + if (node && node.type !== 'checkbox' && (activeControl === 'gamepad' || activeControl === 'touch')) + { + var rect = node.getBoundingClientRect(); + systemApi.api.system.show_keyboard.post({ + XPosition: rect.x, + YPosition: rect.y, + Width: rect.width, + Height: rect.height + }); + } +}; \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 74670f8..e21bb54 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -145,15 +145,18 @@ export const EmulatorPackageSchema = z.object({ z.object({ type: z.literal(['github', 'gitlab']), pattern: z.string(), - path: z.string() + path: z.string(), + bin: z.string().optional() }), z.object({ type: z.literal('direct'), url: z.url(), + bin: z.string().optional() }), z.object({ type: z.literal('scoop'), url: z.url(), + bin: z.string().optional() }) ]))).optional(), systems: z.array(z.string()), diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index c2396ba..2a03104 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -119,7 +119,7 @@ declare interface CommandEntry /** The front end label for the command. Mainly gotten from ES-DE list */ label?: string; /** Compiled command to be executed */ - command: string; + command: string | string[]; /** Environment variables */ env?: Record, /** The path the spawned process will start at */