diff --git a/.vscode/settings.json b/.vscode/settings.json index 41de9ee..d7ae22e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,8 +35,5 @@ "WAYLAND_DISPLAY": "wayland-0", "XDG_RUNTIME_DIR": "/run/user/1000", "GPG_TTY": "/dev/tty" - }, - "terminal.integrated.shellArgs.linux": [ - "-l" - ] + } } \ No newline at end of file diff --git a/bun.lock b/bun.lock index 612e23b..a26044e 100644 --- a/bun.lock +++ b/bun.lock @@ -14,17 +14,22 @@ "conf": "^15.0.2", "drizzle-orm": "^0.45.1", "elysia": "^1.4.22", + "fs-extra": "^11.3.3", "get-folder-size": "^5.0.0", + "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "systeminformation": "^5.31.1", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", "zod": "^4.3.6", }, "devDependencies": { + "@ap0nia/eden": "^1.0.0-next.22", + "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@hey-api/openapi-ts": "^0.91.0", "@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@tailwindcss/vite": "^4.1.18", @@ -35,7 +40,9 @@ "@tanstack/react-router-devtools": "^1.154.12", "@tanstack/react-router-ssr-query": "^1.157.17", "@tanstack/router-plugin": "^1.157.16", + "@tanstack/zod-adapter": "^1.162.4", "@types/bun": "latest", + "@types/fs-extra": "^11.0.4", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", @@ -69,6 +76,10 @@ }, }, "packages": { + "@ap0nia/eden": ["@ap0nia/eden@1.0.0-next.22", "", { "peerDependencies": { "elysia": "^1.3.1" } }, "sha512-9iH09koK29Yuem80fz8nCt9iHVcJqxUo2QHAr4psI02PhvL70n6aWVo/hlHyYXwOSsSgRQlLl1vPmiulFOUFoA=="], + + "@ap0nia/eden-tanstack-query": ["@ap0nia/eden-tanstack-query@1.0.0-next.22", "", {}, "sha512-eSQ98G4TYzrAdsfRekrvqIrTqrAUFy+YpibZ5fj5KL6/R6FcrS2U2F51iML98baXT4MTpOJARY9p+7x0hiA8Qw=="], + "@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="], "@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="], @@ -385,6 +396,8 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="], + "@tanstack/zod-adapter": ["@tanstack/zod-adapter@1.162.4", "", { "peerDependencies": { "@tanstack/react-router": ">=1.43.2", "zod": "^3.23.8" } }, "sha512-sO4n2o9F7gZKHZb/nW/fMcDaeVcbFZ2a7zCA+GkaHJwRmhKKlQQ0dae9pc8wOMMG+QkfH1Wysq+tg2RNvm/kpg=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -405,8 +418,12 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/jsonfile": ["@types/jsonfile@6.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ=="], + "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], @@ -737,6 +754,8 @@ "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-disk-info": ["node-disk-info@1.3.0", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-NEx858vJZ0AoBtmD/ChBIHLjFTF28xCsDIgmFl4jtGKsvlUx9DU/OrMDjvj3qp/E4hzLN0HvTg7eJx5XFQvbeg=="], + "node-downloader-helper": ["node-downloader-helper@2.1.10", "", { "bin": { "ndh": "bin/ndh" } }, "sha512-8LdieUd4Bqw/CzfZLf30h+1xSAq3riWSDfWKsPJYz8EULoWxjS1vw6BGLYFZDxQgXjDR7UmC9UpQ0oV93U98Fg=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], @@ -907,6 +926,8 @@ "sync-message-port": ["sync-message-port@1.2.0", "", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="], + "systeminformation": ["systeminformation@5.31.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], diff --git a/package.json b/package.json index 1a513dd..287beb6 100644 --- a/package.json +++ b/package.json @@ -30,17 +30,22 @@ "conf": "^15.0.2", "drizzle-orm": "^0.45.1", "elysia": "^1.4.22", + "fs-extra": "^11.3.3", "get-folder-size": "^5.0.0", + "node-disk-info": "^1.3.0", "node-downloader-helper": "^2.1.10", "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "systeminformation": "^5.31.1", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", "zod": "^4.3.6" }, "devDependencies": { + "@ap0nia/eden": "^1.0.0-next.22", + "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@hey-api/openapi-ts": "^0.91.0", "@noriginmedia/norigin-spatial-navigation": "^2.3.0", "@tailwindcss/vite": "^4.1.18", @@ -51,7 +56,9 @@ "@tanstack/react-router-devtools": "^1.154.12", "@tanstack/react-router-ssr-query": "^1.157.17", "@tanstack/router-plugin": "^1.157.16", + "@tanstack/zod-adapter": "^1.162.4", "@types/bun": "latest", + "@types/fs-extra": "^11.0.4", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index f64bbea..5cf5396 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -19,6 +19,7 @@ import { ActiveGame } from "../types/types"; import EventEmitter from "node:events"; import { ErrorLike } from "bun"; import { getErrorMessage } from "../utils"; +import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; export const config = new Conf({ projectName: projectPackage.name, @@ -44,9 +45,10 @@ const fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), console.log("Cookie Jar Path Located At: ", fileCookieStore.filePath); export const jar = new CookieJar(fileCookieStore); await fs.mkdir(config.get('downloadPath'), { recursive: true }); -const sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true }); -export const db = drizzle(sqlite, { schema }); -migrate(db, { migrationsFolder: "./drizzle" }); +let sqlite: Database; +export let db: DrizzleSqliteDODatabase; +await reloadDatabase(); +migrate(db!, { migrationsFolder: "./drizzle" }); const emulatorsSqlite = new Database(`./vendors/es-de/emulators.${os.platform()}.${os.arch()}.sqlite`, { readonly: true }); export const emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); export const taskQueue = new TaskQueue(); @@ -77,9 +79,15 @@ export async function cleanup () emulatorsSqlite.close(); } +export async function reloadDatabase () +{ + sqlite = new Database(path.join(config.get('downloadPath'), 'db.sqlite'), { create: true, readwrite: true }); + db = drizzle(sqlite, { schema }); +} + interface AppEventMap { - activegameexit: [{ subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }]; + activegameexit: [{ source: string, id: number, subprocess?: Bun.Subprocess, exitCode: number | null, signalCode: number | null, error?: ErrorLike; }]; exitapp: []; notification: [Notification]; } \ No newline at end of file diff --git a/src/bun/api/drives.ts b/src/bun/api/drives.ts new file mode 100644 index 0000000..a5f2969 --- /dev/null +++ b/src/bun/api/drives.ts @@ -0,0 +1,91 @@ +import { Drive } from "@/shared/constants"; +import si from 'systeminformation'; +import fs from 'node:fs'; +import os from "node:os"; + +async function getAccess (path: string) +{ + let hasWriteAccess = false; + try + { + await fs.promises.access(path, fs.constants.W_OK); + hasWriteAccess = true; + } catch (error) + { + + } + + let hasReadAccesss = false; + try + { + await fs.promises.access(path, fs.constants.R_OK); + hasReadAccesss = true; + } catch (error) + { + + } + + return [hasReadAccesss, hasWriteAccess]; +} + +export async function getDevices (): Promise +{ + const blockDevicesRaw = await si.blockDevices(); + const layout = await si.diskLayout(); + const blockDevices = blockDevicesRaw.filter(l => l.device && l.type === 'part' && l.mount); + const fsSizes = await si.fsSize(); + const sizes = new Map(fsSizes.map(s => [s.mount, s])); + const layoutMap = new Map(layout.map(l => [l.device, l])); + return await Promise.all(blockDevices.map(async l => + { + const size = sizes.get(l.mount); + const layout = layoutMap.get(l.device!); + const [hasReadAccess, hasWriteAccess] = await getAccess(l.mount); + const drive: Drive = { + parent: l.group || null, + device: l.device ?? '', + label: l.label || l.name, + mountPoint: l.mount, + type: l.type as any, + size: l.size, + used: size?.used ?? l.size, + isRemovable: l.removable, + interfaceType: layout?.interfaceType || null, + hasReadAccess, + hasWriteAccess + }; + return drive; + })); +} + +// Gets hand picked locations on drives that you have permission to write to +export async function getDevicesCurated (): Promise +{ + const drives: Drive[] = []; + const devices = await getDevices(); + drives.push(...devices.filter(d => d.hasWriteAccess)); + + const homeDir = os.homedir(); + const homeDirDevice = devices.filter(d => d.mountPoint).reverse() + .find(d => homeDir.startsWith(d.mountPoint!)); + if (homeDirDevice) + { + const [hasReadAccess, hasWriteAccess] = await getAccess(homeDir); + + drives.push({ + parent: homeDirDevice.parent, + device: homeDirDevice.device, + size: homeDirDevice.size, + used: homeDirDevice.used, + isRemovable: homeDirDevice.isRemovable, + mountPoint: homeDir, + type: homeDirDevice.type, + label: 'Home', + interfaceType: homeDirDevice.interfaceType, + hasReadAccess, + hasWriteAccess + }); + } + + return drives; +} \ No newline at end of file diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 8394d40..b4df373 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -4,15 +4,15 @@ import { and, eq, getTableColumns } from "drizzle-orm"; import z from "zod"; import * as schema from "../schema/app"; import fs from "node:fs/promises"; -import { FrontEndGameType, FrontEndGameTypeDetailed } from "@shared/constants"; +import { FrontEndGameType, FrontEndGameTypeDetailed, GameListFilterSchema } from "@shared/constants"; import { getRomApiRomsIdGet, getRomsApiRomsGet, updateRomUserApiRomsIdPropsPut } from "@clients/romm"; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { calculateSize, checkInstalled, convertRomToFrontend, convertRomToFrontendDetailed, getLocalGameMatch } from "./services/utils"; import buildStatusResponse, { getValidLaunchCommandsForGame } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; +import { launchCommand } from "./services/launchGameService"; import { getErrorMessage } from "@/bun/utils"; -import { spawn } from "node:child_process"; export default new Elysia() .get('/game/local/:id/cover', async ({ params: { id }, set }) => @@ -55,54 +55,64 @@ export default new Elysia() }, { params: z.object({ id: z.number() }), response: z.object({ installed: z.boolean() }) - }).get('/games', async ({ query: { platform_id, collection_id } }) => + }).get('/games', async ({ query: { platform_source, platform_slug, platform_id, collection_id } }) => { const where: any[] = []; - if (platform_id) + if (platform_slug) { - where.push(eq(schema.games.id, platform_id)); + where.push(eq(schema.platforms.slug, platform_slug)); } const games: FrontEndGameType[] = []; + let localGamesSet: Set | undefined; - const localGames = await db.select({ - platform_display_name: schema.platforms.name, - id: schema.games.id, - last_played: schema.games.last_played, - created_at: schema.games.created_at, - platform_id: schema.games.platform_id, - slug: schema.games.slug, - name: schema.games.name, - path_fs: schema.games.path_fs, - source_id: schema.games.source_id, - source: schema.games.source - }).from(schema.games).leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)).where(and(...where)); - - const localGamesSet = new Set(localGames.map(g => g.source_id)); - games.push(...localGames.map(g => + if (!collection_id) { - const game: FrontEndGameType = { - ...g, - platform_display_name: g.platform_display_name ?? "Local", - id: { id: g.id, source: 'local' }, - updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, - source_id: g.source_id, - source: g.source, - path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover` - }; - return game; - })); + const localGames = await db.select({ + platform_display_name: schema.platforms.name, + id: schema.games.id, + last_played: schema.games.last_played, + created_at: schema.games.created_at, + platform_id: schema.games.platform_id, + slug: schema.games.slug, + name: schema.games.name, + path_fs: schema.games.path_fs, + source_id: schema.games.source_id, + source: schema.games.source + }).from(schema.games) + .leftJoin(schema.platforms, eq(schema.games.platform_id, schema.platforms.id)) + .where(and(...where)); - const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true }); - games.push(...rommGames.data.items.filter(g => !localGamesSet.has(g.id)).map(g => + localGamesSet = new Set(localGames.filter(g => !!g.source_id).map(g => g.source_id!)); + games.push(...localGames.map(g => + { + const game: FrontEndGameType = { + ...g, + platform_display_name: g.platform_display_name ?? "Local", + id: { id: g.id, source: 'local' }, + updated_at: g.created_at, + path_cover: `/api/romm/game/local/${g.id}/cover`, + source_id: g.source_id, + source: g.source, + path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover` + }; + return game; + })); + } + + if ((!platform_source || platform_source === 'romm') || !!collection_id) { - return convertRomToFrontend(g); - })); + const rommGames = await getRomsApiRomsGet({ query: { platform_ids: platform_id ? [platform_id] : undefined, collection_id }, throwOnError: true }); + games.push(...rommGames.data.items.filter(g => !localGamesSet?.has(g.id)).map(g => + { + return convertRomToFrontend(g); + })); + } + return { games }; }, { - query: z.object({ platform_id: z.coerce.number().optional(), collection_id: z.coerce.number().optional() }), + query: GameListFilterSchema, }) .get('/game/:source/:id', async ({ params: { source, id } }) => { @@ -188,7 +198,7 @@ export default new Elysia() { if (!taskQueue.hasActive()) { - taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id)); + taskQueue.enqueue(`install-rom-${source}-${id}`, new InstallJob(id, source, id)); return status(200); } else { @@ -209,97 +219,14 @@ export default new Elysia() } else { - - if (activeGame && activeGame.process.killed === false) - { - return status('Conflict', `${activeGame.name} currently running`); - } - - const localGame = await db.query.games.findFirst({ - where: eq(schema.games.id, validCommand.gameId), columns: { - name: true, - source_id: true, - source: true - } - }); - try { - await new Promise((resolve, reject) => - { - const game = spawn(validCommand.command.command, { - shell: true - }); - game.stdout.on('data', data => console.log(data)); - game.on('close', (code) => - { - events.emit('activegameexit', { exitCode: code, signalCode: null }); - resolve(code); - }); - game.on('error', e => - { - events.emit('activegameexit', { exitCode: null, signalCode: null, error: e }); - console.error(e); - }); - - setActiveGame({ - pid: game.pid, - name: localGame?.name ?? "Unknown", - gameId: validCommand.gameId, - command: validCommand.command.command - }); - - function updateRommProps (id: number) - { - updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } }); - events.emit('notification', { message: "Updated Last Played", type: 'success' }); - } - - if (source === 'romm') - { - updateRommProps(id); - } - else if (localGame?.source === 'romm' && localGame.source_id) - { - updateRommProps(localGame.source_id); - } - - }); - - /* - const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]); - const game = setActiveGame({ - process: Bun.spawn({ - cmd, - env: { - ...process.env - }, - onExit (subprocess, exitCode, signalCode, error) - { - events.emit('activegameexit', { subprocess, exitCode, signalCode, error }); - }, - stdin: "ignore", - stdout: "inherit", - stderr: "inherit", - }), - name: localGame?.name ?? "Unknown", - gameId: validCommand.gameId, - command: validCommand.command.command - }); - - await game.process.exited; - if (game.process.exitCode && game.process.exitCode > 0) - { - return status('Internal Server Error'); - }*/ - return status('OK'); - + await launchCommand(validCommand.command.command, source, id, validCommand.gameId); } catch (error) { + console.error(error); return status('Internal Server Error', getErrorMessage(error)); } - - } } }, { diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index c342059..eed4b78 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -12,6 +12,14 @@ export default new Elysia() const platforms: FrontEndPlatformType[] = []; let rommPlatformsSet: Set | undefined; const { data: rommPlatforms } = await getPlatformsApiPlatformsGet(); + + const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) }) + .from(schema.platforms) + .leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id)) + .groupBy(schema.platforms.id); + + const localPlatformSet = new Set(localPlatforms.filter(p => p.game_count > 0).map(p => p.slug)); + if (rommPlatforms) { const frontEndPlatforms = rommPlatforms.map(p => @@ -24,22 +32,17 @@ export default new Elysia() game_count: p.rom_count, updated_at: new Date(p.updated_at), id: { source: 'romm', id: p.id }, - source: null, - source_id: null + hasLocal: localPlatformSet.has(p.slug) }; return platform; }); + rommPlatformsSet = new Set(rommPlatforms.map(p => p.slug)); platforms.push(...frontEndPlatforms); } - const localPlatforms = await db.select({ ...getTableColumns(schema.platforms), game_count: count(schema.games.id) }) - .from(schema.platforms) - .leftJoin(schema.games, eq(schema.games.platform_id, schema.platforms.id)) - .groupBy(schema.platforms.id) - .where(notInArray(schema.platforms.slug, Array.from(rommPlatformsSet ?? []))); - platforms.push(...localPlatforms.map(p => + platforms.push(...localPlatforms.filter(p => !rommPlatformsSet?.has(p.slug)).map(p => { const platform: FrontEndPlatformType = { slug: p.slug, @@ -49,8 +52,7 @@ export default new Elysia() game_count: p.game_count, updated_at: p.created_at, id: { source: 'local', id: p.id }, - source: null, - source_id: null + hasLocal: true }; return platform; diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index dda0f1b..a056b32 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -3,10 +3,13 @@ import { which } from 'bun'; import fs from 'node:fs/promises'; import { existsSync, readFileSync } from 'node:fs'; import * as schema from '../../schema/emulators'; +import * as appSchema from "../../schema/app"; import { eq } from 'drizzle-orm'; -import { config, emulatorsDb } from '../../app'; +import { activeGame, config, db, emulatorsDb, events, setActiveGame } from '../../app'; import os from 'node:os'; import { $ } from 'bun'; +import { spawn } from 'node:child_process'; +import { updateRomUserApiRomsIdPropsPut } from '@/clients/romm'; export const varRegex = /%([^%]+)%/g; @@ -18,6 +21,92 @@ interface CommandEntry emulator?: string; } +export async function launchCommand (validCommand: string, source: string, sourceId: number, id: number) +{ + if (activeGame && activeGame.process?.killed === false) + { + throw new Error(`${activeGame.name} currently running`); + } + + const localGame = await db.query.games.findFirst({ + where: eq(appSchema.games.id, id), columns: { + name: true, + source_id: true, + source: true + } + }); + + await new Promise((resolve, reject) => + { + const game = spawn(validCommand, { + shell: true + }); + game.stdout.on('data', data => console.log(data)); + game.on('close', (code) => + { + events.emit('activegameexit', { source, id: sourceId, exitCode: code, signalCode: null }); + resolve(code); + }); + game.on('error', e => + { + console.error(e); + events.emit('notification', { message: e.message, type: 'error' }); + reject(e); + }); + + setActiveGame({ + process: game, + name: localGame?.name ?? "Unknown", + gameId: id, + command: validCommand + }); + + function updateRommProps (id: number) + { + updateRomUserApiRomsIdPropsPut({ path: { id }, body: { update_last_played: true } }); + events.emit('notification', { message: "Updated Last Played", type: 'success' }); + } + + if (source === 'romm') + { + updateRommProps(sourceId); + } + else if (localGame?.source === 'romm' && localGame.source_id) + { + updateRommProps(localGame.source_id); + } + + }); + + /* Old spawn lanching, cases issues, needs to be ran as shell + + const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]); + const game = setActiveGame({ + process: Bun.spawn({ + cmd, + env: { + ...process.env + }, + onExit (subprocess, exitCode, signalCode, error) + { + events.emit('activegameexit', { subprocess, exitCode, signalCode, error }); + }, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }), + name: localGame?.name ?? "Unknown", + gameId: validCommand.gameId, + command: validCommand.command.command + }); + + await game.process.exited; + if (game.process.exitCode && game.process.exitCode > 0) + { + return status('Internal Server Error'); + }*/ +} + export async function getValidLaunchCommands (data: { systemSlug: string; gamePath: string; @@ -90,11 +179,11 @@ export async function getValidLaunchCommands (data: { const staticVars: Record = { '%ROM%': $.escape(rom), '%ROMRAW%': validFiles[0], - '%ROMRAWWIN%': validFiles[0].replace('/', '\\'), - '%ESPATH%': path.dirname(Bun.main), + '%ROMRAWWIN%': $.escape(validFiles[0].replace('/', '\\')), + '%ESPATH%': $.escape(path.dirname(Bun.main)), '%ROMPATH%': $.escape(gamePath), - '%BASENAME%': path.basename(validFiles[0], path.extname(validFiles[0])), - '%FILENAME%': path.basename(validFiles[0]) + '%BASENAME%': $.escape(path.basename(validFiles[0], path.extname(validFiles[0]))), + '%FILENAME%': $.escape(path.basename(validFiles[0])) }; cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (subscring, injectFile: string) => diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index ea0a439..f46cc6b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -79,19 +79,39 @@ export async function getValidLaunchCommandsForGame (source: string, id: number) export default async function buildStatusResponse (source: string, id: number) { let cleanup: (() => void) | undefined; + let closed = false; return new Response(new ReadableStream({ async start (controller) { - function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh') + const encoder = new TextEncoder(); + + function enqueue (data: GameInstallProgress, event?: 'error' | 'refresh' | 'ping') { const evntString = event ? `event: ${event}\n` : ''; - controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`); + controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`)); } + await sendLatests(); + + // seems to help with issue of buffers not flushing, keeping the connection open forcefully + const keepAlive = setInterval(() => + { + if (closed) return clearInterval(keepAlive); + try + { + enqueue({}, 'ping'); + } catch + { + closed = true; + clearInterval(keepAlive); + } + }, 15000); + const sourceId = `${source}-${id}`; async function sendLatests () { + if (closed) return; const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(id, source), columns: { id: true } }); const activeTask = taskQueue.findJob(`install-rom-${source}-${id}`); if (activeTask) @@ -136,8 +156,6 @@ export default async function buildStatusResponse (source: string, id: number) } } - await sendLatests(); - const dispose: Function[] = []; const handleActiveExit = async (data: { error?: ErrorLike; }) => { @@ -179,6 +197,7 @@ export default async function buildStatusResponse (source: string, id: number) cleanup = () => { + closed = true; dispose.forEach(f => f()); }; }, diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 09b04fd..91a185b 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -2,16 +2,13 @@ import { IJob, JobContext } from "../task-queue"; import { mkdir } from 'node:fs/promises'; import { and, eq, or } from 'drizzle-orm'; import fs from 'node:fs/promises'; -import { DownloaderHelper } from 'node-downloader-helper'; -import StreamZip from 'node-stream-zip'; import * as schema from "../schema/app"; import * as emulatorSchema from "../schema/emulators"; import path from 'node:path'; -import { downloadRomsApiRomsDownloadGet, getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm"; +import { getPlatformApiPlatformsIdGet, getRomApiRomsIdGet } from "@clients/romm"; import { config, db, emulatorsDb, jar } from "../app"; import unzip from 'unzip-stream'; import { Readable, Transform } from "node:stream"; -import { createWriteStream } from "node:fs"; interface JobConfig { @@ -22,13 +19,17 @@ interface JobConfig export class InstallJob implements IJob { public id: number; + public source: string; + public sourceId: number; public config?: JobConfig; - constructor(id: number, config?: JobConfig) + constructor(id: number, source: string, sourceId: number, config?: JobConfig) { this.id = id; this.config = config; + this.sourceId = sourceId; + this.source = source; } public async start (cx: JobContext) diff --git a/src/bun/api/notifications.ts b/src/bun/api/notifications.ts index 6446747..c20a67d 100644 --- a/src/bun/api/notifications.ts +++ b/src/bun/api/notifications.ts @@ -3,16 +3,33 @@ import { events } from './app'; export default function buildNotificationsStream () { + let closed = false; let cleanup: (() => void) | undefined = undefined; return new ReadableStream({ async start (controller) { + + const encoder = new TextEncoder(); function enqueue (data: Notification, event?: 'notification') { const evntString = event ? `event: ${event}\n` : ''; - controller.enqueue(`${evntString}data: ${JSON.stringify(data)}\n\n`); + controller.enqueue(encoder.encode(`${evntString}data: ${JSON.stringify(data)}\n\n`)); } + // seems to help with issue of buffers not flushing, keeping the connection open forcefully + const keepAlive = setInterval(() => + { + if (closed) return clearInterval(keepAlive); + try + { + controller.enqueue(encoder.encode(`: ping\n\n`)); + } catch + { + closed = true; + clearInterval(keepAlive); + } + }, 15000); + const notificationHandler = (notification: Notification) => { enqueue(notification, 'notification'); @@ -23,6 +40,7 @@ export default function buildNotificationsStream () cancel: () => { cleanup?.(); + closed = true; } }); } \ No newline at end of file diff --git a/src/bun/api/rpc.ts b/src/bun/api/rpc.ts index 87e0503..a1544f2 100644 --- a/src/bun/api/rpc.ts +++ b/src/bun/api/rpc.ts @@ -1,10 +1,10 @@ import { cors } from "@elysiajs/cors"; import Elysia from "elysia"; import { RPC_PORT } from "../../shared/constants"; -import { host } from "../utils"; import clients from "./clients"; import { settings } from "./settings"; import { system } from "./system"; +import { host } from "../utils/host"; const api = new Elysia({ serve: {} }) .use([cors(), clients, settings, system]); diff --git a/src/bun/api/secrets.ts b/src/bun/api/secrets.ts index e37dab8..ca4b9bd 100644 --- a/src/bun/api/secrets.ts +++ b/src/bun/api/secrets.ts @@ -118,7 +118,6 @@ class FallbackSecrets implements ISecrets } } -/* try { await Bun.secrets.get({ service: 'test', name: 'test' }); @@ -126,8 +125,6 @@ try } catch { secrets = new FallbackSecrets(); -}*/ - -secrets = new FallbackSecrets(); +} export default secrets; \ No newline at end of file diff --git a/src/bun/api/settings.ts b/src/bun/api/settings.ts index 2914194..2273385 100644 --- a/src/bun/api/settings.ts +++ b/src/bun/api/settings.ts @@ -1,12 +1,15 @@ import z from "zod"; import { SettingsSchema } from "@shared/constants"; -import Elysia from "elysia"; -import { config, customEmulators, db, emulatorsDb } from "./app"; +import Elysia, { status } from "elysia"; +import { config, customEmulators, db, emulatorsDb, taskQueue } from "./app"; import * as appSchema from './schema/app'; import { findExec } from "./games/services/launchGameService"; import * as emulatorSchema from "./schema/emulators"; import { eq, inArray } from 'drizzle-orm'; import fs from 'node:fs/promises'; +import { existsSync } from "node:fs"; +import { InstallJob } from "./jobs/install-job"; +import { move } from "fs-extra"; export const settings = new Elysia({ prefix: '/api/settings' }) .get('/emulators/automatic', async () => @@ -90,6 +93,46 @@ export const settings = new Elysia({ prefix: '/api/settings' }) }, { response: z.array(z.string()) }) + .put('/path/download', async ({ body: { manualPath, drive } }) => + { + if (taskQueue.hasActiveOfType(InstallJob)) + { + return status("Forbidden", "Installation in progress"); + } + + const oldDownloadPath = config.get('downloadPath'); + if (!existsSync(oldDownloadPath)) + { + return status("Not Found", "Old downlod path doesn't exist"); + } + + async function isDirEmpty (dirname: string) + { + const files = await fs.readdir(dirname); + return files.length === 0; + } + + const path = manualPath ?? drive; + + if (!path) + { + return; + } + + if (existsSync(path) && !isDirEmpty(path)) + { + return status("Conflict", "New location alaready exists and is not empty"); + } + + await move(oldDownloadPath, path); + config.set('downloadPath', manualPath); + return manualPath; + }, { + body: z.object({ + manualPath: z.string().optional(), + drive: z.string().optional() + }) + }) .get("/:id", async ({ params: { id } }) => { const value = config.get(id); diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 556a289..b50554a 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -3,24 +3,38 @@ import open from 'open'; import z from "zod"; import os from 'node:os'; import { config, events } from "./app"; -import { isSteamDeckGameMode } from "../utils"; +import { isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; +import path, { dirname } from "node:path"; +import { DirSchema, DownloadsDrive } from "@/shared/constants"; +import { getDevices, getDevicesCurated } from "./drives"; +import getFolderSize from "get-folder-size"; +import si from 'systeminformation'; // steam://open/keyboard?XPosition=%i&YPosition=%i&Width=%i&Height=%i&Mode=%d export const system = new Elysia({ prefix: '/api/system' }) - .post('/show_keyboard', async () => + .post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) => { - if (isSteamDeckGameMode()) + if (await isSteamDeck()) { - open('steam://open/keyboard'); + const url = new URL('steam://open/keyboard'); + if (XPosition) url.searchParams.set('XPosition', String(XPosition)); + if (YPosition) url.searchParams.set('YPosition', String(YPosition)); + if (Width) url.searchParams.set('Width', String(Width)); + if (Height) url.searchParams.set('Height', String(Height)); + open(url.href); } + }, { + body: z.object({ + XPosition: z.coerce.number().optional(), + YPosition: z.coerce.number().optional(), + Width: z.coerce.number().optional(), + Height: z.coerce.number().optional() + }) }) .get('/info', async () => { - - const downloadStats = await fs.statfs(config.get('downloadPath')); - return { homeDir: os.homedir(), user: os.userInfo().username, @@ -29,9 +43,6 @@ export const system = new Elysia({ prefix: '/api/system' }) hostname: os.hostname(), steamDeck: process.env.SteamDeck, machine: os.machine(), - freeSpace: downloadStats.bsize * downloadStats.bavail, - totalSpace: downloadStats.bsize * downloadStats.blocks, - downloadsType: downloadStats.type }; }) .get('/notifications', ({ set }) => @@ -41,13 +52,105 @@ export const system = new Elysia({ prefix: '/api/system' }) set.headers['connection'] = 'keep-alive'; return new Response(buildNotificationsStream()); }) + .get('/info/battery', async () => + { + return si.battery(); + }) + .get('/info/wifi', async () => + { + return si.wifiConnections(); + }) + .get('/info/bluetooth', async () => + { + return si.bluetoothDevices(); + }) + .get('/drives', async () => + { + const drives = await getDevices(); + return drives; + }) + .get('/drives/download', async () => + { + const drives = await getDevicesCurated(); + const downloadsPath = config.get('downloadPath'); + const currentDownloadsSize = await getFolderSize(downloadsPath); + let used = false; + const drivesDownload: DownloadsDrive[] = drives + .filter(d => !!d.mountPoint) + .map(d => ({ ...d, depth: d.mountPoint!.split(path.sep).length })) + .sort((a, b) => b.depth - a.depth) + .map(d => + { + const drive: DownloadsDrive = { + device: d.device, + label: d.label, + mountPoint: path.join(d.mountPoint!, 'gameflow'), + isRemovable: d.isRemovable, + size: d.size, + used: d.used, + isCurrentlyUsed: false, + unusableReason: null + }; + + if (!used && d.mountPoint && downloadsPath.startsWith(d.mountPoint)) + { + drive.isCurrentlyUsed = true; + used = true; + } + + if (!drive.isCurrentlyUsed && currentDownloadsSize && drive.size - drive.used <= currentDownloadsSize.size) + { + drive.unusableReason = 'not_enough_space'; + } + else if (drive.isCurrentlyUsed && downloadsPath === drive.mountPoint) + { + drive.unusableReason = 'already_used'; + } + + return drive; + }); + return { + downloadsSize: currentDownloadsSize.size, + configPath: dirname(config.path), + drives: drivesDownload, + }; + }) + .put('/dirs', async ({ body: { dirname, name } }) => + { + await fs.mkdir(path.join(dirname, name)); + }, { + body: z.object({ dirname: z.string(), name: z.string() }) + }) + .get('/dirs', async ({ query: { path: startingPath } }) => + { + const currentPath = startingPath ?? dirname(Bun.main); + const paths = await fs.readdir(currentPath, { withFileTypes: true }); + return { + name: path.basename(currentPath), + parentPath: path.dirname(currentPath), + dirs: paths.sort((a, b) => (b.isDirectory() ? 1 : 0) - (a.isDirectory() ? 1 : 0)).map(p => + ({ + name: p.name, + parentPath: p.parentPath, + isDirectory: p.isDirectory() + })) + }; + }, + { + query: z.object({ path: z.string().optional() }), + response: z.object({ + name: z.string(), + parentPath: z.string(), + dirs: z.array(DirSchema) + }) + }) .post('/exit', () => { events.emit('exitapp'); }) - .post('/open', async ({ query: { url } }) => + .post('/open', async ({ body: { url } }) => { - open(url); + await openExternal(url); }, { - query: z.object({ url: z.url() }) + body: z.object({ url: z.string() }) }); \ No newline at end of file diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 6294009..e00c039 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -46,6 +46,18 @@ export class TaskQueue return this.activeQueue.length > 0; } + public hasActiveOfType (type: any) + { + for (const entry of this.activeQueue) + { + if (entry.context.job instanceof type) + { + return true; + } + } + return false; + } + public waitForJob (id: string): Promise { const job = this.queue?.find(j => j.context.id === id) ?? this.activeQueue?.find(j => j.context.id === id); diff --git a/src/bun/browser.ts b/src/bun/browser.ts index 3249a3f..86aa036 100644 --- a/src/bun/browser.ts +++ b/src/bun/browser.ts @@ -2,6 +2,8 @@ import { killBrowser, spawnBrowser } from './utils/browser-spawner'; import { BuildParams } from './utils/browser-params'; import os from 'node:os'; import { EventEmitter } from 'node:stream'; +import { config } from './api/app'; +import { dirname } from 'node:path'; export default async function init (events: EventEmitter, forceBrowser: boolean) { @@ -51,7 +53,7 @@ async function runWebview (events: EventEmitter) async function runBrowser (events: EventEmitter) { - const browserParams = await BuildParams(); + const browserParams = await BuildParams({ configPath: dirname(config.path) }); if (!browserParams) { console.error("Could not find valid browser"); @@ -68,6 +70,7 @@ async function runBrowser (events: EventEmitter) detached: false, execPath: browserParams.browser.path, source: browserParams.browser.source, + configPath: dirname(config.path), ipc (message) { console.log(message); diff --git a/src/bun/server.ts b/src/bun/server.ts index 48c70d6..13fd176 100644 --- a/src/bun/server.ts +++ b/src/bun/server.ts @@ -1,7 +1,7 @@ import { SERVER_PORT } from "../shared/constants"; import path from 'node:path'; -import { host } from "./utils"; import appInfo from '../../package.json'; +import { host } from "./utils/host"; export function RunBunServer () { diff --git a/src/bun/types/types.d.ts b/src/bun/types/types.d.ts index e5d8f5a..3cd0398 100644 --- a/src/bun/types/types.d.ts +++ b/src/bun/types/types.d.ts @@ -1,7 +1,9 @@ +import { ChildProcess } from "node:child_process"; + declare const IS_BINARY: string; export type ActiveGame = { - pid?: number; + process?: ChildProcess; gameId: number; name: string; command: string; diff --git a/src/bun/utils.ts b/src/bun/utils.ts index d6465d2..dc4d928 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -1,11 +1,5 @@ -import { networkInterfaces } from 'node:os'; - -const localIp = Object.values(networkInterfaces()) - .flat() - .find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || 'localhost'; - -export const host = process.env.PUBLIC_ACCESS ? localIp : 'localhost'; +import { $ } from 'bun'; export function checkRunning (pid: number) { @@ -27,4 +21,38 @@ export function getErrorMessage (error: unknown): string export function isSteamDeckGameMode () { return !!Bun.env.SteamDeck; +} + +export async function isSteamDeck () +{ + if (process.platform === 'linux') + { + try + { + const productName = await Bun.file("/sys/class/dmi/id/product_name").text(); + const isSteamDeck = ["Jupiter", "Galileo"].includes(productName.trim()); + return isSteamDeck; + } catch (error) + { + return isSteamDeckGameMode(); + } + } +} + +export async function openExternal (target: string) +{ + if (process.platform === "linux") + { + return $`xdg-open ${target}`.throws(true); + } + + if (process.platform === "win32") + { + return $`cmd /c start ${target}`.throws(true); + } + + if (process.platform === "darwin") + { + return $`open ${target}`.throws(true); + } } \ No newline at end of file diff --git a/src/bun/utils/browser-params.ts b/src/bun/utils/browser-params.ts index 2f38abe..698c104 100644 --- a/src/bun/utils/browser-params.ts +++ b/src/bun/utils/browser-params.ts @@ -1,11 +1,13 @@ import { SERVER_URL } from "../../shared/constants"; import os from 'node:os'; -import path, { dirname } from 'node:path'; +import path from 'node:path'; import { getBrowserPath } from "./get-browser"; -import { host, isSteamDeckGameMode } from "../utils"; +import { isSteamDeckGameMode } from "../utils"; import { config } from "../api/app"; +import { ensureDir } from 'fs-extra'; +import { host } from "./host"; -export async function BuildParams () +export async function BuildParams (data: { configPath: string; }) { const validBrowser = await getBrowserPath({ browserOrder: ['chrome', 'chromium'] @@ -28,15 +30,19 @@ export async function BuildParams () const isEdge = validBrowser.path.toLowerCase().includes('edge') || validBrowser.path.toLowerCase().includes('msedge'); console.log(`[Browser] Detected: ${validBrowser.type} from ${validBrowser.source} - ${isEdge ? 'Edge' : 'Chrome/Chromium'}`); + const dataPath = path.join(data.configPath, 'browser-data'); + await ensureDir(dataPath); + args.push(`--app=${SERVER_URL(host)}`); args.push(`--app-id=gameflow`); args.push(`--force-app-mode`); args.push('--no-default-browser-check'); + args.push('--new-instance'); args.push('--no-first-run'); args.push('--disable-infobars'); args.push("--disable-extensions"); args.push("--disable-plugins"); - args.push(`--user-data-dir=${path.join(dirname(config.path), 'browser-data')}`); + args.push(`--user-data-dir=${dataPath}`); args.push('--disable-sync'); //Disable syncing to a Google account args.push('--disable-sync-preferences'); args.push('--disable-component-update'); diff --git a/src/bun/utils/browser-spawner.ts b/src/bun/utils/browser-spawner.ts index 673c3c4..005bf05 100644 --- a/src/bun/utils/browser-spawner.ts +++ b/src/bun/utils/browser-spawner.ts @@ -1,7 +1,4 @@ import { $, type Subprocess } from "bun"; -import path from 'node:path'; -import { readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; import os from 'node:os'; export type RunBrowserType = "chrome" | "chromium" | "firefox" | "edge"; @@ -25,6 +22,7 @@ interface SpawnBrowserOptions detached?: boolean; execPath: string; // Required: browser executable path from get-browser.ts source: RunBrowserSource; // How the browser was discovered (running, system, or flatpak) + configPath: string; onExit?: () => void; // Called when the browser exists duh ipc?: (message: string) => void; } @@ -69,7 +67,8 @@ export async function spawnBrowser ({ execPath, source, onExit, - ipc + ipc, + configPath }: SpawnBrowserOptions): Promise { // Configuration for both Flatpak and Native @@ -117,6 +116,7 @@ export async function spawnBrowser ({ "--branch=stable", `--arch=${process.arch === "x64" ? "x86_64" : process.arch}`, // map node arch to flatpak arch `--command=${target.internalCmd}`, + `--filesystem=${configPath}`, // we must allw it to use our own config path to save profile data "--file-forwarding", ...envFlags // Inject env vars here ]; diff --git a/src/bun/utils/host.ts b/src/bun/utils/host.ts new file mode 100644 index 0000000..3ed7fb6 --- /dev/null +++ b/src/bun/utils/host.ts @@ -0,0 +1,7 @@ +import { networkInterfaces } from "node:os"; + +const localIp = Object.values(networkInterfaces()) + .flat() + .find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || 'localhost'; + +export const host = process.env.PUBLIC_ACCESS ? localIp : 'localhost'; \ No newline at end of file diff --git a/src/bun/webview/base.ts b/src/bun/webview/base.ts index 69d279b..e1ce1a0 100644 --- a/src/bun/webview/base.ts +++ b/src/bun/webview/base.ts @@ -1,6 +1,6 @@ import { SERVER_URL } from "@/shared/constants"; import Webview from "@rcompat/webview"; -import { host } from "../utils"; +import { host } from "../utils/host"; export default function (webview: Webview) { diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 655d36b..1066557 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -9,12 +9,13 @@ import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; import { Router } from '..'; import { PopSource } from '../scripts/spatialNavigation'; +import { GameListFilterType } from '@/shared/constants'; export interface CollectionsDetailParams { id?: string; setBackground: (url: string) => void; - filters: GameListFilter; + filters?: GameListFilterType; headerTitle?: JSX.Element; title?: JSX.Element; footer?: JSX.Element; @@ -32,7 +33,7 @@ function HandleGoBack () export function CollectionsDetail (data: CollectionsDetailParams) { - const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`; + const focusKey = `game-list-${data.id}-${data.filters ? Object.values(data.filters).map(f => String(f)).join(",") : ''}`; const { ref, focusSelf } = useFocusable({ focusKey, preferredChildFocusKey: `${focusKey}-list`, @@ -51,7 +52,14 @@ export function CollectionsDetail (data: CollectionsDetailParams)
{data.title} - + node.scrollIntoView({ block: 'center', behavior: 'smooth' })} + id={`${focusKey}-list`}> + +
diff --git a/src/mainview/components/ContextDialog.tsx b/src/mainview/components/ContextDialog.tsx index c623d43..f22ce26 100644 --- a/src/mainview/components/ContextDialog.tsx +++ b/src/mainview/components/ContextDialog.tsx @@ -2,20 +2,19 @@ import { FocusContext, FocusDetails, setFocus, useFocusable } from "@noriginmedi import classNames from "classnames"; import { createContext, JSX, useContext, useEffect } from "react"; import { twMerge } from "tailwind-merge"; -import { useEventListener } from "usehooks-ts"; import { X } from "lucide-react"; -import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; const ContextDialogContext = createContext({} as { close: () => void, id: string; }); -export function ContextList (data: { options: DialogEntry[]; className?: string; showCloseButton?: boolean; }) +export function ContextList (data: { options?: DialogEntry[]; className?: string; showCloseButton?: boolean; }) { const context = useContext(ContextDialogContext); - return
    - {data.options.map(o => )} + return
      + {data.options?.map(o => )} {data.showCloseButton !== false && } action={context.close} id="close" content="Close" />}
    ; } @@ -29,30 +28,37 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class data.onFocus?.(); }; const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined; - const { ref, focused, focusSelf } = useFocusable({ + const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({ focusKey: `${context.id}-list-option-${data.id}`, onEnterPress: handleAction, - onFocus: handleFocus + onFocus: handleFocus, + trackChildren: typeof data.content !== 'string' }); const colors = { - primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused }), - secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused }), - accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused }), - info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused }), - warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused }), - error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused }) + primary: classNames("hover:bg-primary/40", { "bg-primary text-primary-content": focused || hasFocusedChild }), + secondary: classNames("hover:bg-secondary/40", { "bg-secondary text-secondary-content": focused || hasFocusedChild }), + accent: classNames("hover:bg-accent/40", { "bg-accent text-accent-content": focused || hasFocusedChild }), + info: classNames("hover:bg-info/40", { "bg-info text-info-content": focused || hasFocusedChild }), + warning: classNames("hover:bg-warning/40", { "bg-warning text-warning-content": focused || hasFocusedChild }), + error: classNames("hover:bg-error/40", { "bg-error text-error-content": focused || hasFocusedChild }) }; + if (data.shortcuts) + { + useShortcuts(focusKey, () => data.shortcuts!, [data.shortcuts]); + } return
  • -

    - {data.icon} - {data.content} -

    + +
    + {data.icon} + {data.content} +
    +
  • ; } @@ -63,11 +69,23 @@ export interface DialogEntry icon?: string | JSX.Element; type: 'primary' | 'secondary' | 'accent' | 'info' | 'warning' | 'error'; action?: (ctx: { close: () => void, focus: (focusDetails?: FocusDetails | undefined) => void; }) => void; + shortcuts?: Shortcut[]; } -export function ContextDialog (data: { id: string, children: any | any[], open: boolean, close: () => void; }) +export function ContextDialog (data: { + id: string, + children: any | any[], + open: boolean, close: () => void; + className?: string; + preferredChildFocusKey?: string; +}) { - const { ref, focusKey, focusSelf } = useFocusable({ focusable: data.open, focusKey: `${data.id}-context-dialog`, isFocusBoundary: true }); + const { ref, focusKey, focusSelf } = useFocusable({ + focusable: data.open, + focusKey: `${data.id}-context-dialog`, + isFocusBoundary: true, + preferredChildFocusKey: data.preferredChildFocusKey + }); useEffect(() => { if (data.open) @@ -76,14 +94,14 @@ export function ContextDialog (data: { id: string, children: any | any[], open: } }, [data.open]); - useShortcuts(focusKey, () => [{ + useShortcuts(focusKey, () => data.open ? [{ label: "Close", button: GamePadButtonCode.B, action: () => { data.close(); } - }], []); + }] : [], [data.open]); return
    e.stopPropagation()} > diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx new file mode 100644 index 0000000..97d1154 --- /dev/null +++ b/src/mainview/components/FilePicker.tsx @@ -0,0 +1,286 @@ +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; +import { ContextList, DialogEntry, OptionElement } from "./ContextDialog"; +import { systemApi } from "../scripts/clientApi"; +import { createContext, useContext, useRef, useState } from "react"; +import path from "pathe"; +import { Check, File, Folder, FolderClosed, FolderInput, FolderOutput, FolderPlus, HardDrive, Plus, Save, Undo, Usb, X } from "lucide-react"; +import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; +import { DirType, Drive } from "@/shared/constants"; +import classNames from "classnames"; +import { twMerge } from "tailwind-merge"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; +import SvgIcon from "./SvgIcon"; +import { Button } from "./options/Button"; +import toast from "react-hot-toast"; +import { drivesQuery, filesQuery } from "../scripts/queries"; + +const FilePickerContext = createContext<{ + allowNewFolderCreation: boolean; + isDirectoryPicker: boolean; + setCurrentPath: (path: string) => void; + currentPath: string | undefined, + startingPath: string | undefined; + refetchFiles: () => void; + drives: Drive[], + activeDrive: Drive | undefined; +}>({} as any); + +function List (data: { + id: string, + parentPath: string, + dirs: DirType[], + select: (path: string) => void; + +}) +{ + const { setCurrentPath, startingPath, allowNewFolderCreation, currentPath, isDirectoryPicker } = useContext(FilePickerContext); + const { ref, focusKey } = useFocusable({ focusKey: data.id, preferredChildFocusKey: `${data.id}...` }); + const handleReturn = () => setCurrentPath(data.parentPath); + useShortcuts(focusKey, () => [{ label: "Directoy Up", button: GamePadButtonCode.L1, action: handleReturn }], [handleReturn]); + return
    + + ...
    , + icon: , + shortcuts: [{ label: "Up", action: handleReturn, button: GamePadButtonCode.A }] + }, + ...data.dirs.map(f => + { + const fullPath = path.join(f.parentPath, f.name); + const isDefaultPath = fullPath === startingPath; + let icon = ; + if (isDefaultPath) + { + icon = ; + } else if (!f.isDirectory) + { + icon = <>; + } + const shortcuts: Shortcut[] = []; + if (f.isDirectory) + { + shortcuts.push({ label: "Enter", button: GamePadButtonCode.A, action: () => setCurrentPath(fullPath) }); + if (isDirectoryPicker) + shortcuts.push({ label: "Select", button: GamePadButtonCode.X, action: () => data.select(fullPath) }); + } else + { + shortcuts.push({ label: "Select", button: GamePadButtonCode.A, action: () => data.select(fullPath) }); + } + const entry: DialogEntry = { + content: f.name, + id: `${data.id}-${f.name}`, + type: 'primary', + icon, + shortcuts + }; + return entry; + }), ...(allowNewFolderCreation && currentPath ? [{ + content: , + id: `${data.id}-new-folder`, + type: 'primary' + } satisfies DialogEntry] : [])] + } /> + +
    ; +} + +function NewFolderInput (data: { id: string, name: string | undefined, setName: (name: string) => void; className?: string; }) +{ + const inputRef = useRef(null); + const { ref, focused, focusSelf } = useFocusable({ + focusKey: data.id, + onEnterPress: () => inputRef.current?.focus(), + onBlur: () => inputRef.current?.blur(), + }); + const handleFocus = () => + { + focusSelf(); + systemApi.api.system.show_keyboard.post(); + }; + return
    + data.setName(e.target.value)} + /> +
    ; +} + +function NewFolderOption (data: { id: string, dirname: string; }) +{ + const { refetchFiles } = useContext(FilePickerContext); + const [name, setName] = useState(); + const createMutation = useMutation({ + mutationKey: ['create', 'folder', data.id], mutationFn: async () => + { + if (!name) return; + const { error } = await systemApi.api.system.dirs.put({ name, dirname: data.dirname }); + if (error) throw error.value; + }, + onError: (e) => toast.error(e.message ?? 'Error Creating New Folder'), + onSuccess: (d, v, r, cx) => + { + toast.success(`Folder ${name} created`); + refetchFiles(); + } + }); + return
    + + +
    ; +} + +function OptionButtons (data: { + id: string; + onCancel: () => void; + onSelect: () => void; + showConfirm: boolean; +}) +{ + const { ref, focusKey } = useFocusable({ focusKey: `options-${data.id}`, onEnterPress: data.onSelect }); + return
    + + {data.showConfirm && } + + +
    ; +} + +function DriveElement (data: { id: string, isActive: boolean, label: string; onSelect: () => void; isRemovable: boolean; }) +{ + const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect }); + return
  • + {data.isRemovable ? : } + {data.label} +
  • ; +} + +function Drives (data: { + id: string, + onSelect: (path: string) => void; +}) +{ + const { drives, activeDrive } = useContext(FilePickerContext); + const { focusKey, ref } = useFocusable({ + focusKey: data.id, + preferredChildFocusKey: activeDrive?.mountPoint ?? undefined, + saveLastFocusedChild: false, + autoRestoreFocus: false + }); + + return
      + + {drives?.filter(d => d.mountPoint) + .sort((a, b) => b.mountPoint!.length - a.mountPoint!.length) + .map(d => + data.onSelect(d.mountPoint!)} id={d.mountPoint!} isActive={activeDrive?.mountPoint === d.mountPoint} label={d.label} /> + )} + +
    ; +} + +function ListWithDrives (data: { + id: string, + files: DirType[], + onSelect: (path: string) => void, + parentPath: string; +}) +{ + const { setCurrentPath, isDirectoryPicker } = useContext(FilePickerContext); + const { focusKey, ref } = useFocusable({ + focusKey: `main-${data.id}`, + preferredChildFocusKey: `list-${data.id}` + }); + return
    + +
    + setCurrentPath(p)} id={`drives-${data.id}`} /> +
    +
    +
    +
    + + { + if (isDirectoryPicker && !d.isDirectory) + { + return false; + } + return true; + })} parentPath={data.parentPath} select={data.onSelect} /> +
    +
    +
    ; +} + +export default function FilePicker (data: { + id: string; + startingPath?: string; + onSelect: (path: string) => void; + isDirectoryPicker?: boolean; + cancel: () => void; + allowNewFolderCreation?: boolean; +}) +{ + const [currentPath, setCurrentPath] = useState(data.startingPath); + + const { data: files, refetch: refetchFiles } = useQuery(filesQuery(currentPath, data.id)); + const { data: drives } = useQuery(drivesQuery); + + const fullPath = files ? path.join(files.parentPath, files.name) : ''; + const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0]; + const activeDriveMount = activeDrive?.mountPoint; + const fullPathElements = activeDrive?.label ? + [<>{activeDrive?.label}, ...fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep)] : + fullPath.substring(activeDriveMount?.length ?? 0).split(path.sep); + + return
    + + {!!fullPath && +
    + +
    } + + + currentPath ? data.onSelect(currentPath) : undefined} + id={data.id} /> +
    +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/GameCard.tsx b/src/mainview/components/GameCard.tsx index e35ba9f..5b14777 100644 --- a/src/mainview/components/GameCard.tsx +++ b/src/mainview/components/GameCard.tsx @@ -79,11 +79,11 @@ export default function GameCard (data: GameCardParams) typeof data.preview === 'function' ? data.preview({ focused }) : data.preview )} -
    +
    {data.badges?.map((b, i) =>
    {b} diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index 4dd48c2..5f342f9 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,22 +1,16 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; -import { FrontEndId, RPC_URL } from "../../shared/constants"; +import { FrontEndId, GameListFilterType, RPC_URL } from "@shared/constants"; import { useNavigate } from "@tanstack/react-router"; import { SaveSource } from "../scripts/spatialNavigation"; import { rommApi } from "../scripts/clientApi"; import { HardDrive } from "lucide-react"; import { JSX } from "react"; -export interface GameListFilter -{ - platformId?: number; - collectionId?: number; -} - export interface GameListParams { id: string, - filters?: GameListFilter, + filters?: GameListFilterType, grid?: boolean, setBackground?: (url: string) => void; onGameSelect?: (id: FrontEndId) => void; @@ -29,10 +23,7 @@ export function GameList (data: GameListParams) const games = useSuspenseQuery({ queryKey: ['games', data.filters ?? 'all'], queryFn: () => rommApi.api.romm.games.get({ - query: { - platform_id: data.filters?.platformId, - collection_id: data.filters?.collectionId - } + query: data.filters }).then(d => d.data) }); const navigator = useNavigate(); diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index bd44d24..8ba0201 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -6,7 +6,11 @@ import import classNames from "classnames"; import { + BatteryCharging, BatteryFull, + BatteryLow, + BatteryMedium, + BatteryWarning, Bell, Bluetooth, Clock, @@ -16,14 +20,18 @@ import Sun, User, Wifi, + WifiHigh, + WifiLow, + WifiZero, } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useQuery } from "@tanstack/react-query"; import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen"; import { RPC_URL } from "../../shared/constants"; -import { JSX } from "react"; +import { JSX, useEffect, useRef } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { SaveSource } from "../scripts/spatialNavigation"; +import { systemApi } from "../scripts/clientApi"; function HeaderAvatar (data: { id: string; @@ -104,11 +112,128 @@ export interface HeaderAccount action?: () => void; } +function NotificationStatus () +{ + const hasUnread = false; + return
    + +
    ; +} + +function ClockStatus () +{ + const ref = useRef(null); + useEffect(() => + { + function update () + { + if (ref.current) + { + ref.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + } + + // Update immediately + update(); + + // Wait until next minute boundary + const now = new Date(); + const msUntilNextMinute = + (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); + + const timeout = setTimeout(() => + { + update(); + + // Then update every minute + const interval = setInterval(update, 60_000); + return () => clearInterval(interval); + }, msUntilNextMinute); + + return () => clearTimeout(timeout); + }, []); + + return
    ; +} + +function BluetoothStatus () +{ + const { data: bluetooth } = useQuery({ + queryKey: ['wifi'], + queryFn: () => systemApi.api.system.info.bluetooth.get().then(d => d.data), + refetchInterval: 3000 + }); + return bluetooth && bluetooth.find(b => b.connected) &&
    + +
    ; +} + +function WiFiStatus () +{ + const { data: wifi } = useQuery({ + queryKey: ['wifi'], + queryFn: () => systemApi.api.system.info.wifi.get().then(d => d.data), + refetchInterval: 3000 + }); + + return
    + {wifi?.map(w => + { + const className = "w-6 h-6"; + let icon = ; + if (w.signalLevel >= -60) + icon = ; + else if (w.signalLevel >= -70) + icon = ; + else if (w.signalLevel >= -80) + icon = ; + else if (w.signalLevel >= -90) + icon = ; + + return
    + {icon} +
    ; + })} + +
    ; +} + +function BatteryStatus () +{ + const { data: battery } = useQuery({ + queryKey: ['battery'], + queryFn: () => systemApi.api.system.info.battery.get().then(d => d.data), + refetchInterval: 3000 + }); + const batteryClassName = "w-6 h-6"; + let batteryIcon = ; + if (battery?.isCharging || battery?.acConnected) + { + batteryIcon = ; + } else if (battery?.percent) + { + if (battery.percent < 5) + { + batteryIcon = ; + } + else if (battery.percent < 15) + { + batteryIcon = ; + } else if (battery.percent < 50) + { + batteryIcon = ; + } + } + return
    + {batteryIcon} + {battery?.percent} % +
    ; +} + export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAccount[], buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; }) { const { ref, focusKey } = useFocusable({ focusKey: "header-elements" }); const navigate = useNavigate(); - const location = useLocation(); const rommOnline = useQuery({ ...statsApiStatsGetOptions(), refetchInterval: 30000, @@ -161,18 +286,12 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc {data.title}
    -
    - - - -
    - - -
    -
    - - 100% -
    +
    + + + + +
    {!!data.buttons &&
    }
    diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 40f16f9..f81f5c9 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -5,6 +5,8 @@ import { CardList, GameMetaExtra } from "./CardList"; import classNames from "classnames"; import { rommApi } from "../scripts/clientApi"; import { SaveSource } from "../scripts/spatialNavigation"; +import { JSX } from "react"; +import { HardDrive } from "lucide-react"; export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: (node: HTMLElement) => void; }) { @@ -29,42 +31,48 @@ export function PlatformsList (data: { id: string, setBackground: (url: string) className={data.className} onGameFocus={(id, node) => data.onFocus?.(node)} games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime()) - .map((g) => ({ - id: g.slug, - focusKey: g.slug, - title: g.name, - subtitle: g.family_name ?? "", - previewUrl: "", - badges: [( - {g.game_count} - )], - onFocus: () => data.setBackground( - `https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`, - ), - onSelect: () => - { - SaveSource('game-list'); - navigate({ to: `/platform/${g.source ?? g.id.source}/${g.source_id ?? g.id.id}`, viewTransition: { types: ['zoom-in'] } }); - }, - preview: - ({ focused }) =>
    + { + const badges: JSX.Element[] = []; + badges.push({g.game_count}); + if (g.hasLocal) + badges.push(); + const entry: GameMetaExtra = { + id: g.slug, + focusKey: g.slug, + title: g.name, + subtitle: g.family_name ?? "", + previewUrl: "", + badges, + onFocus: () => data.setBackground( + `https://picsum.photos/id/${10 + g.slug.length}/1920/1080.webp`, + ), + onSelect: () => + { + SaveSource('game-list'); + navigate({ to: `/platform/${g.id.source}/${g.id.id}`, viewTransition: { types: ['zoom-in'] } }); + }, + preview: + ({ focused }) =>
    - -
    - , - } satisfies GameMetaExtra))} + backgroundBlendMode: "screen", + boxShadow: 'inset 0 0 32px rgba(0,0,0,0.6)' + }} + > + +
    + , + }; + return entry; + })} onSelectGame={(id) => { diff --git a/src/mainview/components/Shortcuts.tsx b/src/mainview/components/Shortcuts.tsx index 0e35066..31de171 100644 --- a/src/mainview/components/Shortcuts.tsx +++ b/src/mainview/components/Shortcuts.tsx @@ -26,11 +26,11 @@ const iconMap: Record = { export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) { return ( -
    +
    {data.shortcuts?.filter(s => !!s.label).map((s, i) => s.action(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} + onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} icon={iconMap[s.button]} label={s.label} /> )} diff --git a/src/mainview/components/options/Button.tsx b/src/mainview/components/options/Button.tsx index 93d8e77..894a493 100644 --- a/src/mainview/components/options/Button.tsx +++ b/src/mainview/components/options/Button.tsx @@ -5,22 +5,39 @@ import useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; +import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; -export function Button (data: { id: string, children?: any, className?: string, disabled?: boolean, type: "reset" | "button" | "submit" | undefined; } & InteractParams & FocusParams) +export function Button (data: { + id: string, + children?: any, + className?: string, + disabled?: boolean, + type: "reset" | "button" | "submit" | undefined; + shortcutLabel?: string; + focusClassName?: string; +} & InteractParams & FocusParams) { - const { ref, focused } = useFocusable({ + const { ref, focused, focusKey } = useFocusable({ focusKey: data.id, onEnterPress: data.onAction, onFocus: data.onFocus, focusable: !data.disabled }); + + if (data.shortcutLabel) + { + useShortcuts(focusKey, () => [{ label: data.shortcutLabel, action: data.onAction, button: GamePadButtonCode.A }], [data.shortcutLabel]); + } + return + {data.requireConfirmation === true && } + + + {isBrowsing && + } + + {data.children} + + ); +} \ No newline at end of file diff --git a/src/mainview/components/options/SettingsOption.tsx b/src/mainview/components/options/SettingsOption.tsx index fde062b..fab772c 100644 --- a/src/mainview/components/options/SettingsOption.tsx +++ b/src/mainview/components/options/SettingsOption.tsx @@ -15,6 +15,7 @@ export function SettingsOption (data: { type: HTMLInputTypeAttribute; placeholder?: string; icon?: JSX.Element; + children?: any; }) { const [dirty, setDirty] = useState(false); @@ -67,6 +68,7 @@ export function SettingsOption (data: { }} value={localValue} /> + {data.children} ); } \ No newline at end of file diff --git a/src/mainview/index.tsx b/src/mainview/index.tsx index 940ca92..4d09964 100644 --- a/src/mainview/index.tsx +++ b/src/mainview/index.tsx @@ -9,12 +9,11 @@ import RouterProvider, } from "@tanstack/react-router"; import { routeTree } from "./gen/routeTree.gen"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RPC_URL } from "../shared/constants"; import "./scripts/gamepads"; import "./scripts/windowEvents"; import { client as rommClient } from "../clients/romm/client.gen"; -import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; import "./scripts/spatialNavigation"; const hashHistory = createHashHistory({}); @@ -50,6 +49,8 @@ export const Router = createRouter({ }, }); + + // Register things for typesafety declare module "@tanstack/react-router" { interface Register @@ -58,12 +59,6 @@ declare module "@tanstack/react-router" { } } -setupRouterSsrQueryIntegration({ - router: Router, - queryClient, - wrapQueryClient: true, -}); - const rootElement = document.getElementById("root")!; if (!rootElement.innerHTML) @@ -71,7 +66,9 @@ if (!rootElement.innerHTML) const root = createRoot(rootElement); root.render( - + + + , ); } diff --git a/src/mainview/routes/collection.$id.tsx b/src/mainview/routes/collection.$id.tsx index 7f0795d..a9abfce 100644 --- a/src/mainview/routes/collection.$id.tsx +++ b/src/mainview/routes/collection.$id.tsx @@ -1,8 +1,9 @@ import { createFileRoute } from '@tanstack/react-router'; import { useSessionStorage } from 'usehooks-ts'; import { CollectionsDetail } from '../components/CollectionsDetail'; -import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen'; +import { getCollectionApiCollectionsIdGetOptions, getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen'; import { DefaultRommStaleTime } from '../../shared/constants'; +import { useQuery } from '@tanstack/react-query'; export const Route = createFileRoute('/collection/$id')({ component: RouteComponent, @@ -15,12 +16,13 @@ export const Route = createFileRoute('/collection/$id')({ function RouteComponent () { const { id } = Route.useParams(); + const { data: collection } = useQuery({ ...getCollectionApiCollectionsIdGetOptions({ path: { id: Number(id) } }) }); const [, setBackground] = useSessionStorage( "home-background", undefined, ); return ( - + {collection?.name}
    } filters={{ collection_id: Number(id) }} /> ); } diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index caa27c0..90fdd45 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -205,7 +205,15 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; }) mutationFn: async () => { const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post(); - if (error) throw error; + if (error) + { + if (error.value.message) + { + toast.error(error.value.message); + } + + throw error; + }; } }); const [progress, setProgress] = useState(undefined); diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 34522ea..1734902 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -13,23 +13,16 @@ import import { createFileRoute, - useLocation, useNavigate, } from "@tanstack/react-router"; -import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { FocusContext, useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; -import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants"; import { useEventListener } from "usehooks-ts"; -import -{ - getCollectionsApiCollectionsGetOptions, -} from "../../clients/romm/@tanstack/react-query.gen"; -import { CardList, GameMetaExtra } from "../components/CardList"; import { HeaderUI } from "../components/Header"; import { FilterUI } from "../components/Filters"; import { AnimatedBackground, AnimatedBackgroundContext } from "../components/AnimatedBackground"; @@ -47,10 +40,11 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/ import z from "zod"; import { Router } from ".."; import CollectionList from "../components/CollectionList"; +import { zodValidator } from '@tanstack/zod-adapter'; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, - validateSearch: z.object({ filter: z.string().optional().default('games') }) + validateSearch: zodValidator(z.object({ filter: z.string().optional().default('games') })) }); const filters = { diff --git a/src/mainview/routes/launcher.$source.$id.tsx b/src/mainview/routes/launcher.$source.$id.tsx index 687b4e8..4c60350 100644 --- a/src/mainview/routes/launcher.$source.$id.tsx +++ b/src/mainview/routes/launcher.$source.$id.tsx @@ -7,6 +7,9 @@ import { Router } from '..'; import { useEffect, useState } from 'react'; import { rommApi } from '../scripts/clientApi'; import { useQuery } from '@tanstack/react-query'; +import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts'; +import { useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import Shortcuts from '../components/Shortcuts'; export const Route = createFileRoute('/launcher/$source/$id')({ component: RouteComponent, @@ -20,13 +23,11 @@ function RouteComponent () } const { source, id } = Route.useParams(); + const { ref, focusKey } = useFocusable({ focusKey: `launching-${source}-${id}` }); const { data } = useQuery({ queryKey: ['romm', 'game'], queryFn: () => rommApi.api.romm.game({ source })({ id }).get() }); - useEventListener("cancel", (e) => - { - e.stopPropagation(); - HandleGoBack(); - }); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); + const { shortcuts } = useShortcutContext(); useEffect(() => { @@ -41,18 +42,27 @@ function RouteComponent () } }; - es.addEventListener('refresh', HandleGoBack); + es.addEventListener('refresh', () => + { + HandleGoBack(); + }); - es.onerror = HandleGoBack; + es.onerror = () => + { + HandleGoBack(); + }; return () => es.close(); }, []); - return + return

    Launching {data?.data?.name} ...

    +
    + +
    ; } diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index ece4a68..dc72c37 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -1,7 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEventListener, useSessionStorage } from "usehooks-ts"; import { CollectionsDetail } from "../components/CollectionsDetail"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants"; import { Suspense } from "react"; import { rommApi } from "../scripts/clientApi"; @@ -10,10 +10,21 @@ export const Route = createFileRoute("/platform/$source/$id")({ component: RouteComponent }); -function PlatformTitle () +function PlatformTitle (data: { platformSlug?: string, platformName?: string; }) +{ + return
    + +
    + {!!data.platformSlug && } + {data.platformName} +
    +
    ; +} + +function RouteComponent () { const { source, id } = Route.useParams(); - const { data: platform } = useSuspenseQuery({ + const { data: platform } = useQuery({ queryKey: ['platform', source, id], queryFn: async () => { const { data, error } = await rommApi.api.romm.platforms({ source })({ id }).get(); @@ -22,33 +33,18 @@ function PlatformTitle () }, staleTime: DefaultRommStaleTime }); - return
    - -
    - - {platform.display_name} -
    -
    ; -} - -function RouteComponent () -{ - const { id } = Route.useParams(); - const [, setBackground] = useSessionStorage( "home-background", undefined, ); - const navigate = useNavigate(); - useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ['zoom-out'] } })); return (
    - } + {!!platform && } setBackground={setBackground} - filters={{ platformId: Number(id) }} - /> + filters={{ platform_id: Number(id), platform_slug: platform.slug, platform_source: source }} + />}
    ); } diff --git a/src/mainview/routes/settings/about.tsx b/src/mainview/routes/settings/about.tsx index 9b465dd..1906c7d 100644 --- a/src/mainview/routes/settings/about.tsx +++ b/src/mainview/routes/settings/about.tsx @@ -51,10 +51,6 @@ function RouteComponent () Machine {systemInfo?.data?.machine} - - Space - {!!systemInfo?.data && `${prettyBytes(systemInfo?.data?.freeSpace)} Free / ${prettyBytes(systemInfo?.data?.totalSpace)} Total | ${(1 - (systemInfo?.data?.freeSpace / systemInfo?.data?.totalSpace)).toLocaleString('en-GB', { style: "percent" })}`} - Steam Deck {systemInfo?.data?.steamDeck ?? 'false'} diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index e9e85fd..bd5cd7e 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -1,11 +1,68 @@ import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { createFileRoute } from '@tanstack/react-router'; -import { SettingsOption } from '../../components/options/SettingsOption'; +import { Block, createFileRoute, useBlocker } from '@tanstack/react-router'; +import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption'; +import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query'; +import { changeDownloadsMutation, downloadDrivesQuery } from '@/mainview/scripts/queries'; +import { DownloadsDrive } from '@/shared/constants'; +import prettyBytes from 'pretty-bytes'; +import classNames from 'classnames'; +import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { Download, FolderOpen, HardDrive, Usb } from 'lucide-react'; +import { twMerge } from 'tailwind-merge'; +import { OptionSpace } from '@/mainview/components/options/OptionSpace'; +import data from '@emulators'; +import { Button } from '@/mainview/components/options/Button'; +import { systemApi } from '@/mainview/scripts/clientApi'; export const Route = createFileRoute('/settings/directories')({ component: RouteComponent, }); +function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; }) +{ + const { ref, focused, focusKey } = useFocusable({ focusKey: data.drive.device }); + const isMoving = useIsMutating(changeDownloadsMutation); + const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0); + const usedPercent = usedWithoutDownlods / data.drive.size; + const usedPercentRaw = data.drive.used / data.drive.size; + const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason; + const shortcuts: Shortcut[] = []; + if (!data.drive.unusableReason && isMoving <= 0) + { + shortcuts.push({ label: "Move Downloads", button: GamePadButtonCode.A, action: () => changeDownloads.mutate(data.drive.mountPoint) }); + } + useShortcuts(focusKey, () => shortcuts, [shortcuts]); + + + return
  • +
    {data.drive.isRemovable ? : }{data.drive.label}
    + {data.drive.mountPoint} +
    + {prettyBytes(data.drive.size - data.drive.used)} Free + {data.drive.unusableReason === 'not_enough_space' &&

    (Not Enough Space)

    } + {data.drive.unusableReason === 'already_used' &&

    (Currently Used)

    } + {data.drive.unusableReason !== 'already_used' && data.drive.isCurrentlyUsed &&

    (Custom Path)

    } +
    + +
    0.8, + "progress-error": data.drive.unusableReason === 'not_enough_space', + }))}> +
    0.8, + "bg-error": data.drive.unusableReason === 'not_enough_space', + }))} style={{ width: usedPercent.toLocaleString('en-US', { style: 'percent' }) }}>
    + {!!data.drive.isCurrentlyUsed &&
    } +
    +
  • ; +} + function RouteComponent () { const { focus } = Route.useSearch(); @@ -13,14 +70,34 @@ function RouteComponent () preferredChildFocusKey: focus }); + const isMoving = useIsMutating(changeDownloadsMutation); + const { data: drives, refetch } = useQuery({ ...downloadDrivesQuery, refetchInterval: isMoving > 0 ? 1000 : undefined }); + return + isMoving} withResolver={false} />
      -
      -

      Romm

      -
      + Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : '?'})
      - +
        + {drives?.drives.filter(d => d.mountPoint).map(d => )} +
      + + + + +
      + {drives?.configPath} + +
      +
    -
    ; + + ; } diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 4e0133c..1890838 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -5,7 +5,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { settingsApi } from '../../scripts/clientApi'; import { useCallback, useState } from 'react'; import { Button } from '../../components/options/Button'; -import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react'; +import { Check, ChevronDown, FolderSearch, SearchAlert, Trash, TriangleAlert } from 'lucide-react'; import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; @@ -13,6 +13,8 @@ import { RPC_URL } from '../../../shared/constants'; import emulators from '@emulators'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import FilePicker from '@/mainview/components/FilePicker'; +import { dirname } from 'pathe'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -90,6 +92,7 @@ function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAdd function EmulatorPath (data: { id: string; }) { + const [isSearching, setIsSearching] = useState(false); const [dirty, setDirty] = useState(false); const [localValue, setLocalValue] = useState(); const { data: remoteValue } = useQuery({ @@ -109,6 +112,8 @@ function EmulatorPath (data: { id: string; }) { ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] }); ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] }); + setLocalValue(v); + setDirty(false); } }); const deleteMutation = useMutation({ @@ -129,11 +134,23 @@ function EmulatorPath (data: { id: string; }) { if (dirty) { - setDirty(false); setSettingMutation.mutate(localValue ?? ''); } }, [dirty, setDirty, localValue]); + const handleCloseSearch = () => + { + setIsSearching(false); + setFocus(`search-${data.id}`); + }; + + const handleSelectPath = (path: string) => + { + setIsSearching(false); + setSettingMutation.mutate(path); + setFocus(`search-${data.id}`); + }; + return (

    {data.id}

    {emulators[data.id]}}>
    @@ -150,9 +167,33 @@ function EmulatorPath (data: { id: string; }) }} value={localValue} /> - + + + {isSearching && + } +
    ); diff --git a/src/mainview/routes/settings/route.tsx b/src/mainview/routes/settings/route.tsx index 5e40ec3..5631351 100644 --- a/src/mainview/routes/settings/route.tsx +++ b/src/mainview/routes/settings/route.tsx @@ -53,7 +53,7 @@ function MenuItem (data: { const acitve = matchRoute({ to: data.route }); const handleNonFocusSelect = () => navigate({ to: data.return ? PopSource('settings') ?? data.route : data.route, viewTransition: data.viewTransition }); const { ref, focusSelf, focused } = useFocusable({ - focusKey: data.route, + focusKey: `menu-item-${data.route}`, forceFocus: !!acitve, onFocus: () => { @@ -119,8 +119,8 @@ function SettingsMenu (data: {}) /> } /> (RPC_URL(__HOST__), { credentials: 'include', } }); + export const settingsApi = treaty(RPC_URL(__HOST__), { keepDomain: true, fetch: { credentials: 'include', } }); + + export const systemApi = treaty(RPC_URL(__HOST__), { keepDomain: true, fetch: { diff --git a/src/mainview/scripts/gamepads.ts b/src/mainview/scripts/gamepads.ts index c0cf2df..2ddd6e5 100644 --- a/src/mainview/scripts/gamepads.ts +++ b/src/mainview/scripts/gamepads.ts @@ -3,20 +3,16 @@ import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation"; let loopStarted = false; - -window.addEventListener("gamepadconnected", (evt) => +const handleLoop = () => { if (!loopStarted) { requestAnimationFrame(updateStatus); loopStarted = true; } -}); - -window.addEventListener("gamepaddisconnected", (evt) => -{ - -}); +}; +window.addEventListener("gamepadconnected", handleLoop); +import.meta.hot.dispose(() => window.addEventListener('gamepaddisconnected', handleLoop)); const throttleMap = new Map(); const throttleAcceleration = new Map(); @@ -36,7 +32,7 @@ function throttleNav (key: string, dir: string, event: Event) } } -window.addEventListener('keydown', e => +/*window.addEventListener('keydown', e => { if (e.key === 'Escape') { @@ -45,7 +41,7 @@ window.addEventListener('keydown', e => const evn = new Event('cancel', { bubbles: true, cancelable: true }); finalTarget.dispatchEvent(evn); } -}); +});*/ export class GamepadButtonEvent extends Event { diff --git a/src/mainview/scripts/queries.ts b/src/mainview/scripts/queries.ts new file mode 100644 index 0000000..a4baf3b --- /dev/null +++ b/src/mainview/scripts/queries.ts @@ -0,0 +1,54 @@ +import { keepPreviousData, mutationOptions, queryOptions } from "@tanstack/react-query"; +import { settingsApi, systemApi } from "./clientApi"; +import toast from "react-hot-toast"; +import { getErrorMessage } from "react-error-boundary"; + +export const drivesQuery = queryOptions({ + queryKey: ['drives'], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.drives.get(); + if (error) throw error; + return data; + } +}); + +export const downloadDrivesQuery = queryOptions({ + queryKey: ['drives', 'download'], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.drives.download.get(); + if (error) throw error; + return data; + } +}); + +export const filesQuery = (currentPath: string | undefined, id: string) => queryOptions({ + queryKey: ['files', currentPath ?? '', id], + queryFn: async () => + { + const { data, error } = await systemApi.api.system.dirs.get({ query: { path: currentPath } }); + if (error) throw error; + return data; + }, + placeholderData: keepPreviousData +}); + +export const changeDownloadsMutation = mutationOptions({ + mutationKey: ["setting", "downloads"], + mutationFn: async (value: any) => + { + const response = await toast.promise(settingsApi.api.settings.path.download.put({ manualPath: value }).then(d => + { + if (d.error) throw d.error; + return d.data; + }), { + success: e => `Download Moved to ${e}`, + loading: "Moving Download", + error: e => getErrorMessage(e) ?? "Error Moving Download" + }); + + return response; + + } +}); \ No newline at end of file diff --git a/src/mainview/scripts/shortcuts.ts b/src/mainview/scripts/shortcuts.ts index 981bd9b..f9e9227 100644 --- a/src/mainview/scripts/shortcuts.ts +++ b/src/mainview/scripts/shortcuts.ts @@ -32,9 +32,25 @@ export interface Shortcut { label?: string; button: GamePadButtonCode; - action: (e: GamepadButtonEvent) => void; + action?: (e: GamepadButtonEvent) => void; } +let isDirty = false; +const shortcutChangeDispatcher = setInterval(() => +{ + window.dispatchEvent(new Event('shortcutsChanged')); + isDirty = false; +}, 100); +import.meta.hot.dispose(() => clearInterval(shortcutChangeDispatcher)); + +function markDirtyThrottled () +{ + isDirty = true; +} + +window.addEventListener('focuschanged', markDirtyThrottled); +import.meta.hot.dispose(() => window.removeEventListener('focuschanged', markDirtyThrottled)); + export function useShortcutContext () { const [array, setArray] = useState(); @@ -44,7 +60,8 @@ export function useShortcutContext () const handleShortcutRebuild = () => { conflictSet.clear(); - const newArray = GetFocusedTree(getCurrentFocusKey()) + const focusKey = getCurrentFocusKey(); + const newArray = GetFocusedTree(focusKey) .filter(f => shortcutMap.has(f)) .flatMap(f => shortcutMap.get(f)!) .filter(s => @@ -65,7 +82,7 @@ export function useShortcutContext () const event = e as GamepadButtonEvent; if (shortcuts.has(event.button)) { - shortcuts.get(event.button)?.action(event); + shortcuts.get(event.button)?.action?.(event); } else if (event.button === GamePadButtonCode.A) { @@ -74,6 +91,20 @@ export function useShortcutContext () } }; + const handleKeyPress = (e: KeyboardEvent) => + { + if (e.key === 'Escape') + { + shortcuts.get(GamePadButtonCode.B)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.B })); + } else if (e.key === 'Backspace') + { + shortcuts.get(GamePadButtonCode.X)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.X })); + } else if (e.key === ' ') + { + shortcuts.get(GamePadButtonCode.Y)?.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: GamePadButtonCode.Y })); + } + }; + const handleGamepadButtonUp = (e: Event) => { const event = e as GamepadButtonEvent; @@ -108,14 +139,16 @@ export function useShortcutContext () handleShortcutRebuild(); } window.addEventListener('gamepadbuttondown', handleGamepadButtonDown); + window.addEventListener('keydown', handleKeyPress); window.addEventListener('gamepadbuttonup', handleGamepadButtonUp); - window.addEventListener('focuschanged', handleShortcutRebuild); + window.addEventListener('shortcutsChanged', handleShortcutRebuild); return () => { - window.removeEventListener('focuschanged', handleShortcutRebuild); window.removeEventListener('gamepadbuttondown', handleGamepadButtonDown); window.removeEventListener('gamepadbuttonup', handleGamepadButtonUp); + window.removeEventListener('shortcutsChanged', handleShortcutRebuild); + window.removeEventListener('keydown', handleKeyPress); }; }, [array]); @@ -127,6 +160,7 @@ export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps useEffect(() => { shortcutMap.set(focusKey, build()); + markDirtyThrottled(); return () => { diff --git a/src/mainview/scripts/spatialNavigation.ts b/src/mainview/scripts/spatialNavigation.ts index 37c096f..f406880 100644 --- a/src/mainview/scripts/spatialNavigation.ts +++ b/src/mainview/scripts/spatialNavigation.ts @@ -1,5 +1,6 @@ import { + FocusDetails, getCurrentFocusKey, init, SpatialNavigation, @@ -13,7 +14,7 @@ init({ let addFocusable = SpatialNavigation.addFocusable.bind(SpatialNavigation); let removeFocusable = SpatialNavigation.removeFocusable.bind(SpatialNavigation); -let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNavigation); +let setFocus = SpatialNavigation.setFocus.bind(SpatialNavigation); type SaveFocusType = "session" | "local"; @@ -27,7 +28,6 @@ export function SaveSource (id: HistorySourceType, url?: string) { historySourceMap.set(id, finalUrl); } - } export function HasSource (id: HistorySourceType) @@ -95,10 +95,10 @@ export function useFocusEventListener +SpatialNavigation.setFocus = (newFocusKey, focusDetails) => { - setCurrentFocusedKey(newFocusKey, focusDetails); - dispatchFocusedEvent(new Event('focuschanged', { bubbles: true })); + setFocus(newFocusKey, focusDetails); + dispatchFocusedEvent(new CustomEvent('focuschanged', { bubbles: true, detail: focusDetails })); }; SpatialNavigation.addFocusable = (toAdd) => @@ -174,8 +174,6 @@ SpatialNavigation.removeFocusable = ({ focusKey }) => removeFocusable(component); } - - }; SpatialNavigation.saveLastFocusedChildKey = (component, focusKey) => diff --git a/src/mainview/scripts/windowEvents.ts b/src/mainview/scripts/windowEvents.ts index 4cf25cf..2583502 100644 --- a/src/mainview/scripts/windowEvents.ts +++ b/src/mainview/scripts/windowEvents.ts @@ -1,9 +1,11 @@ import { settingsApi } from "./clientApi"; -window.addEventListener("resize", () => +const handleResize = () => { settingsApi.api.settings({ id: 'windowSize' }).post({ value: { width: window.innerWidth, height: window.innerHeight } }); -}); +}; +window.addEventListener("resize", handleResize); +import.meta.hot.dispose(() => window.removeEventListener('resize', handleResize)); let lastWindowPosX: number = window.screenX; let lastWindowPosY: number = window.screenY; @@ -16,4 +18,5 @@ var screenPositionInternal: NodeJS.Timeout = setInterval(() => lastWindowPosX = window.screenX; lastWindowPosY = window.screenY; -}, 1000); \ No newline at end of file +}, 1000); +import.meta.hot.dispose(() => clearInterval(screenPositionInternal)); \ No newline at end of file diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 1d86ea1..36030fd 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -29,6 +29,18 @@ export const SettingsSchema = z.object({ downloadPath: z.string().default('./downloads') }); +export const GameListFilterSchema = z.object({ + platform_source: z.string().optional(), + platform_slug: z.string().optional(), + platform_id: z.coerce.number().optional(), + collection_id: z.coerce.number().optional() +}); + +export type GameListFilterType = z.infer; + +export const DirSchema = z.object({ name: z.string(), parentPath: z.string(), isDirectory: z.boolean() }); +export type DirType = z.infer; + export const CustomEmulatorSchema = z.record(z.string(), z.string()); export interface FrontEndId @@ -40,14 +52,13 @@ export interface FrontEndId export interface FrontEndPlatformType { id: FrontEndId; - source: string | null; - source_id: number | null; slug: string; name: string; family_name?: string | null; path_cover: string | null; game_count: number; updated_at: Date; + hasLocal: boolean; } export interface FrontEndGameType @@ -79,6 +90,33 @@ export interface FrontEndGameTypeDetailed extends FrontEndGameType }; }; +export interface Drive +{ + parent: string | null; + device: string; + label: string; + mountPoint: string | null; + type: string; + size: number; + used: number; + isRemovable: boolean; + interfaceType: string | null; + hasWriteAccess: boolean; + hasReadAccess: boolean; +} + +export interface DownloadsDrive +{ + device: string; + label: string; + mountPoint: string | null; + isRemovable: boolean; + size: number; + used: number; + isCurrentlyUsed: boolean; + unusableReason: 'not_enough_space' | 'already_used' | null; +} + export interface Notification { title?: string; diff --git a/vite.config.ts b/vite.config.ts index 787b7d1..2ac4da4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,9 +5,9 @@ import { tanstackRouter } from '@tanstack/router-plugin/vite'; import { createSvgIconsPlugin } from 'vite-plugin-svg-icons-ng'; import path from "node:path"; import staticAssetsPlugin from 'vite-static-assets-plugin'; -import { host } from "./src/bun/utils"; import os from 'node:os'; import tsconfigPaths from 'vite-tsconfig-paths'; +import { host } from "@/bun/utils/host"; export default defineConfig(() => {