From 38cb7525527b5ad4f6eb284cdad0001fd87eaf7e Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Sun, 10 May 2026 01:46:57 +0300 Subject: [PATCH] feat: Implemented public plugin system accessible from the store. feat: Implemented external ryujinx integration plugin refactor: moved sdk types and schemas to own workspace package fix: Fixed emulator launch with no game --- bun.lock | 128 ++-- package.json | 14 +- scripts/build-sdk.ts | 64 -- scripts/sdk/package.json | 9 - scripts/sdk/sdk.ts | 18 - src/bun/api/app.ts | 5 +- src/bun/api/cache.ts | 5 +- src/bun/api/drives.ts | 2 +- src/bun/api/emulatorjs/emulatorjs.ts | 2 +- src/bun/api/games/collections.ts | 2 +- src/bun/api/games/games.ts | 5 +- src/bun/api/games/platforms.ts | 2 +- .../api/games/services/launchGameService.ts | 2 +- src/bun/api/games/services/statusService.ts | 43 +- src/bun/api/games/services/utils.ts | 2 +- src/bun/api/jobs/bios-download-job.ts | 2 +- src/bun/api/jobs/emulator-download-job.ts | 6 +- src/bun/api/jobs/import-job.ts | 4 +- src/bun/api/jobs/install-job.ts | 4 +- src/bun/api/jobs/jobs.ts | 2 +- src/bun/api/jobs/launch-game-job.ts | 6 +- src/bun/api/jobs/login-job.ts | 2 +- src/bun/api/jobs/plugin-operation-job.ts | 62 ++ src/bun/api/jobs/reload-plugins-job.ts | 2 +- src/bun/api/jobs/self-update-job.ts | 2 +- src/bun/api/jobs/twitch-login-job.ts | 2 +- src/bun/api/jobs/update-store.ts | 70 +- src/bun/api/notifications.ts | 2 +- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 2 +- .../dolphin.ts | 2 +- .../pcsx2.ts | 4 +- .../ppsspp.ts | 4 +- .../com.simeonradivoev.gameflow.xemu/xemu.ts | 2 +- .../xenia.ts | 4 +- .../com.simeonradivoev.gameflow.es/es-de.ts | 4 +- .../rclone.ts | 2 +- .../com.simeonradivoev.gameflow.igdb/igdb.ts | 4 +- .../com.simeonradivoev.gameflow.romm/romm.ts | 4 +- .../package.json | 4 +- .../services.ts | 3 +- .../store.ts | 7 +- src/bun/api/plugins/plugin-manager.ts | 56 +- src/bun/api/plugins/plugins.ts | 40 +- src/bun/api/plugins/register-plugins.ts | 138 ++-- src/bun/api/plugins/services.ts | 62 ++ src/bun/api/schema/app.ts | 3 +- src/bun/api/settings/services.ts | 2 +- src/bun/api/settings/settings.ts | 14 +- .../api/store/services/emulatorsService.ts | 3 +- src/bun/api/store/services/gamesService.ts | 3 +- src/bun/api/store/store.ts | 56 +- src/bun/api/system.ts | 3 +- src/bun/types/types.ts | 18 - src/bun/utils.ts | 3 +- src/bun/utils/downloader.ts | 2 +- src/mainview/components/AppCommunication.tsx | 2 +- src/mainview/components/CollectionsDetail.tsx | 2 +- src/mainview/components/FilePicker.tsx | 2 +- src/mainview/components/FrontEndGameCard.tsx | 2 +- src/mainview/components/GameList.tsx | 5 +- src/mainview/components/GamepadKeyboard.tsx | 2 +- src/mainview/components/HeaderSearchField.tsx | 1 - src/mainview/components/LoadMoreButton.tsx | 1 + src/mainview/components/Notifications.tsx | 2 +- src/mainview/components/SideFilters.tsx | 4 +- src/mainview/components/game/Achievements.tsx | 2 +- .../components/game/ActionButtons.tsx | 2 +- src/mainview/components/game/Details.tsx | 2 +- src/mainview/components/game/GameLookup.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 3 +- .../options/DownloadDirectoryOption.tsx | 3 +- .../components/options/LocalOption.tsx | 2 +- .../components/options/PathSettingsOption.tsx | 3 +- .../components/options/SettingsDropdown.tsx | 3 +- .../components/options/SettingsOption.tsx | 3 +- .../components/store/EmulatorsSection.tsx | 2 +- .../components/store/GamesSection.tsx | 2 +- .../store/MissingEmulatorsSection.tsx | 2 +- .../components/store/StoreEmulatorCard.tsx | 2 +- src/mainview/gen/routeTree.gen.ts | 42 ++ src/mainview/query-options.ts | 3 +- .../routes/collection.$source.$id.tsx | 2 +- src/mainview/routes/game/$source.$id.tsx | 2 +- .../routes/game/update.$source.$id.tsx | 6 +- src/mainview/routes/games.tsx | 2 +- src/mainview/routes/index.tsx | 2 +- src/mainview/routes/platform.$source.$id.tsx | 3 +- src/mainview/routes/settings/accounts.tsx | 3 +- src/mainview/routes/settings/directories.tsx | 5 +- src/mainview/routes/settings/emulators.tsx | 8 +- src/mainview/routes/settings/interface.tsx | 6 +- .../routes/settings/plugin.$source.tsx | 55 +- src/mainview/routes/settings/plugins.tsx | 16 +- src/mainview/routes/settings/update.tsx | 2 +- .../routes/store/details.emulator.$id.tsx | 2 +- .../routes/store/details.plugin.$id.tsx | 161 +++++ src/mainview/routes/store/tab/games.tsx | 4 +- src/mainview/routes/store/tab/index.tsx | 2 +- src/mainview/routes/store/tab/plugins.tsx | 151 +++++ src/mainview/routes/store/tab/route.tsx | 8 +- src/mainview/scripts/contexts.ts | 3 +- src/mainview/scripts/queries/plugins.ts | 50 +- src/mainview/scripts/queries/romm.ts | 4 +- src/mainview/scripts/queries/settings.ts | 10 +- src/mainview/scripts/queries/store.ts | 22 +- src/mainview/scripts/types.ts | 3 +- src/mainview/scripts/utils.ts | 2 +- .../packages/gameflow-sdk}/README.md | 15 + src/packages/gameflow-sdk/build.ts | 27 + .../gameflow-sdk}/hooks/app.ts | 2 +- .../gameflow-sdk}/hooks/auth.ts | 3 +- .../gameflow-sdk}/hooks/emulators.ts | 9 +- .../gameflow-sdk}/hooks/games.ts | 6 +- .../gameflow-sdk}/hooks/store.ts | 3 +- .../gameflow-sdk/index.ts} | 61 +- src/packages/gameflow-sdk/package.json | 51 ++ .../packages/gameflow-sdk}/sdk.tsconfig.json | 22 +- src/packages/gameflow-sdk/shared.ts | 631 ++++++++++++++++++ .../gameflow-sdk}/task-queue.ts | 3 +- src/shared/constants.ts | 209 +----- src/shared/types.schema.ts | 0 src/shared/types.ts | 387 ----------- src/tests/downloads.test.ts | 2 +- tsconfig.json | 1 + 124 files changed, 1918 insertions(+), 1067 deletions(-) delete mode 100644 scripts/build-sdk.ts delete mode 100644 scripts/sdk/package.json delete mode 100644 scripts/sdk/sdk.ts create mode 100644 src/bun/api/jobs/plugin-operation-job.ts create mode 100644 src/bun/api/plugins/services.ts delete mode 100644 src/bun/types/types.ts create mode 100644 src/mainview/routes/store/details.plugin.$id.tsx create mode 100644 src/mainview/routes/store/tab/plugins.tsx rename {scripts/sdk => src/packages/gameflow-sdk}/README.md (53%) create mode 100644 src/packages/gameflow-sdk/build.ts rename src/{bun/api => packages/gameflow-sdk}/hooks/app.ts (88%) rename src/{bun/api => packages/gameflow-sdk}/hooks/auth.ts (81%) rename src/{bun/api => packages/gameflow-sdk}/hooks/emulators.ts (83%) rename src/{bun/api => packages/gameflow-sdk}/hooks/games.ts (92%) rename src/{bun/api => packages/gameflow-sdk}/hooks/store.ts (86%) rename src/{bun/types/types.schema.ts => packages/gameflow-sdk/index.ts} (72%) create mode 100644 src/packages/gameflow-sdk/package.json rename {scripts/sdk => src/packages/gameflow-sdk}/sdk.tsconfig.json (52%) create mode 100644 src/packages/gameflow-sdk/shared.ts rename src/{bun/api => packages/gameflow-sdk}/task-queue.ts (99%) create mode 100644 src/shared/types.schema.ts diff --git a/bun.lock b/bun.lock index 5e7c073..cd65d27 100644 --- a/bun.lock +++ b/bun.lock @@ -7,53 +7,54 @@ "dependencies": { "7zip-bin": "^5.2.0", "@auth/core": "^0.34.3", - "@elysiajs/cors": "^1.4.1", - "@elysiajs/eden": "^1.4.6", - "@jimp/wasm-webp": "^1.6.0", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", - "conf": "^15.0.2", - "drizzle-orm": "^0.45.1", - "elysia": "^1.4.22", - "fs-extra": "^11.3.3", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", "get-folder-size": "^5.0.0", "ini": "^6.0.0", - "jimp": "^1.6.0", + "jimp": "^1.6.1", "mustache": "^4.2.0", "node-7z": "^3.0.0", "node-disk-info": "^1.3.0", - "node-downloader-helper": "^2.1.10", + "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", + "npm-check-updates": "^22.1.1", "open": "^11.0.0", - "p-queue": "^9.1.2", + "p-queue": "^9.2.0", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", - "tapable": "^2.3.0", - "tough-cookie": "^6.0.0", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", "tough-cookie-file-store": "^3.3.0", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", - "zod": "^4.3.6", + "zod": "^4.4.3", }, "devDependencies": { - "@ap0nia/eden": "^1.0.0-next.22", + "@ap0nia/eden": "^1.6.1", "@ap0nia/eden-tanstack-query": "^1.0.0-next.22", "@emulatorjs/emulatorjs": "^4.2.3", - "@hey-api/openapi-ts": "^0.91.0", + "@hey-api/openapi-ts": "^0.91.1", "@noriginmedia/norigin-spatial-navigation": "^3.1.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-form": "^1.28.0", - "@tanstack/react-query": "^5.90.20", - "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router": "^1.157.16", - "@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", + "@tailwindcss/vite": "^4.2.4", + "@tanstack/react-form": "^1.29.1", + "@tanstack/react-query": "^5.100.9", + "@tanstack/react-query-devtools": "^5.100.9", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-router-devtools": "^1.166.13", + "@tanstack/react-router-ssr-query": "^1.166.12", + "@tanstack/router-plugin": "^1.167.35", + "@tanstack/zod-adapter": "^1.166.9", "@types/adm-zip": "^0.5.8", "@types/audiosprite": "^0.7.3", "@types/bun": "latest", @@ -64,11 +65,11 @@ "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", "@types/rclone.js": "^0.6.3", - "@types/react": "^19.2.9", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", - "@vitejs/plugin-react": "^5.1.2", - "adm-zip": "^0.5.16", + "@vitejs/plugin-react": "^5.2.0", + "adm-zip": "^0.5.17", "animate.css": "^4.1.1", "app-builder-bin": "^5.0.0-alpha.13", "audiosprite": "^0.7.2", @@ -76,32 +77,71 @@ "classnames": "^2.5.1", "concurrently": "^9.2.1", "cross-env": "^10.1.0", - "daisyui": "^5.5.14", - "drizzle-kit": "^0.31.9", - "dts-bundle-generator": "^9.5.1", + "daisyui": "^5.5.19", + "drizzle-kit": "^0.31.10", "eden-tanstack-query": "^0.0.9", "howler": "^2.2.4", "lucide-react": "^0.563.0", "pretty-bytes": "^7.1.0", "pretty-ms": "^9.3.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-error-boundary": "^6.1.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-error-boundary": "^6.1.1", "react-hot-toast": "^2.6.0", "react-markdown": "^10.1.0", - "react-qr-code": "^2.0.18", - "sass-embedded": "^1.97.3", + "react-qr-code": "^2.0.21", + "sass-embedded": "^1.99.0", "standard-version": "^9.5.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.4", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", "usehooks-ts": "^3.1.1", - "vite": "^7.3.1", - "vite-plugin-svg-icons-ng": "^1.5.2", + "vite": "^7.3.3", + "vite-plugin-svg-icons-ng": "^1.9.0", "vite-static-assets-plugin": "^1.2.2", "vite-tsconfig-paths": "^6.1.1", - "zod-to-ts": "^2.0.0", + }, + }, + "src/packages/gameflow-sdk": { + "name": "@simeonradivoev/gameflow-sdk", + "version": "1.5.3", + "bin": { + "gameflow-build": "build.ts", + }, + "peerDependencies": { + "7zip-bin": "^5.2.0", + "@auth/core": "^0.34.3", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", + "@phalcode/ts-igdb-client": "^1.0.26", + "cheerio": "^1.2.0", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", + "get-folder-size": "^5.0.0", + "ini": "^6.0.0", + "jimp": "^1.6.1", + "mustache": "^4.2.0", + "node-7z": "^3.0.0", + "node-disk-info": "^1.3.0", + "node-downloader-helper": "^2.1.11", + "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", + "open": "^11.0.0", + "p-queue": "^9.2.0", + "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", + "systeminformation": "^5.31.5", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", + "tough-cookie-file-store": "^3.3.0", + "unzip-stream": "^0.3.4", + "webview-bun": "^2.4.0", + "zod": "^4.4.3", }, }, }, @@ -520,6 +560,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], + "@simeonradivoev/gameflow-sdk": ["@simeonradivoev/gameflow-sdk@workspace:src/packages/gameflow-sdk"], + "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], @@ -948,8 +990,6 @@ "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], - "dts-bundle-generator": ["dts-bundle-generator@9.5.1", "", { "dependencies": { "typescript": ">=5.0.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "duplexer": ["duplexer@0.1.2", "", {}, "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="], @@ -1394,6 +1434,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "npm-check-updates": ["npm-check-updates@22.1.1", "", { "bin": { "npm-check-updates": "build/cli.js", "ncu": "build/cli.js" } }, "sha512-uWSxJW25dy5ZM4SdLsi0VBgPSJlo7u+jARQ6Xql+85YYCoqXU2ZaympAZ6237/oybCq/I4nXddE9S9BTwBfBXA=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "nypm": ["nypm@0.6.4", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw=="], @@ -1884,8 +1926,6 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "zod-to-ts": ["zod-to-ts@2.0.0", "", { "peerDependencies": { "typescript": "^5.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-aHsUgIl+CQutKAxtRNeZslLCLXoeuSq+j5HU7q3kvi/c2KIAo6q4YjT7/lwFfACxLB923ELHYMkHmlxiqFy4lw=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@ap0nia/eden/elysia": ["elysia@1.2.15", "", { "dependencies": { "@sinclair/typebox": "^0.34.15", "cookie": "^1.0.2", "memoirist": "^0.3.0", "openapi-types": "^12.1.3" }, "peerDependencies": { "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-/oUSNb83jIWAGi6uSmbQ7Uy0RSJ9NimbVToSLnYS8jjsGId3zgdHqprsdf4rIMInOmEM8skjsFhZ4x8C5AB6+w=="], diff --git a/package.json b/package.json index da5fcec..bf49ccc 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ }, "packageManager": "bun@1.3.9", "type": "module", + "workspaces": [ + "./src/packages/gameflow-sdk" + ], "scripts": { "dev": "NODE_ENV=development bun run build:vite && conc 'bun run ./scripts/dev.ts'", "dev:hmr": "PUBLIC_ACCESS=true conc -k 'bun run hmr' 'bun run ./scripts/dev.ts'", @@ -27,6 +30,7 @@ "build:prod:vite": "NODE_ENV=production bun run build:vite", "build:dev:vite": "NODE_ENV=development bun run build:vite", "build": "bun run build:vite && bun run ./scripts/package-bun.ts", + "build:non-compiled": "bun run build:vite && NON_COMPILED=true bun run ./scripts/package-bun.ts", "build:prod": "NODE_ENV=production bun run build", "build:linux": "TARGET=bun-linux-x64 bun run build", "openapi-ts": "bun run ./scripts/romm/openapi-ts.ts", @@ -49,8 +53,7 @@ "download:nwjs": "bun scripts/download-nw.ts", "build:audiosprites": "bun ./scripts/generate-audio-sprites.ts", "tsc": "tsc --noEmit", - "build:sdk": "bun ./scripts/build-sdk.ts", - "publish:sdk": "bun build:sdk && bun publish --cwd ./dist-sdk/ --access public" + "publish:sdk": "bun publish --cwd ./src/packages/gameflow-sdk/ --access public" }, "dependencies": { "7zip-bin": "^5.2.0", @@ -73,6 +76,7 @@ "node-downloader-helper": "^2.1.11", "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", + "npm-check-updates": "^22.1.1", "open": "^11.0.0", "p-queue": "^9.2.0", "pathe": "^2.0.3", @@ -126,7 +130,6 @@ "cross-env": "^10.1.0", "daisyui": "^5.5.19", "drizzle-kit": "^0.31.10", - "dts-bundle-generator": "^9.5.1", "eden-tanstack-query": "^0.0.9", "howler": "^2.2.4", "lucide-react": "^0.563.0", @@ -148,7 +151,6 @@ "vite": "^7.3.3", "vite-plugin-svg-icons-ng": "^1.9.0", "vite-static-assets-plugin": "^1.2.2", - "vite-tsconfig-paths": "^6.1.1", - "zod-to-ts": "^2.0.0" + "vite-tsconfig-paths": "^6.1.1" } -} +} \ No newline at end of file diff --git a/scripts/build-sdk.ts b/scripts/build-sdk.ts deleted file mode 100644 index 11ee929..0000000 --- a/scripts/build-sdk.ts +++ /dev/null @@ -1,64 +0,0 @@ -import path from 'node:path'; -import appPkg from '../package.json'; -import sdkTsConfig from './sdk/sdk.tsconfig.json'; -import sdkPackage from './sdk/package.json'; -import { emptyDir } from 'fs-extra'; -import { generateDtsBundle } from 'dts-bundle-generator'; -import { zodToTs, createAuxiliaryTypeStore, printNode } from 'zod-to-ts'; -import fs from 'node:fs/promises'; - -import * as types from './sdk/sdk'; - -const zodTypeRegex = /z\.infer/gm; - -async function generateApiDeclarations () -{ - const tmpConfigPath = "./scripts/sdk/sdk.tsconfig.json"; - const outDir = path.join(path.dirname(tmpConfigPath), sdkTsConfig.compilerOptions.outDir); - await emptyDir(outDir); - - const results = generateDtsBundle([{ - filePath: './scripts/sdk/sdk.ts', - output: { - inlineDeclareGlobals: true, - sortNodes: true, - } - },], { preferredConfigPath: './scripts/sdk/sdk.tsconfig.json' }); - - const auxiliaryTypeStore = createAuxiliaryTypeStore(); - - await Bun.write('./dist-sdk/index.d.ts', results.map(r => - { - const result = r; - return result.replaceAll(zodTypeRegex, (e, name) => - { - const schema = types[name as keyof typeof types]; - if (schema) - { - try - { - const { node } = zodToTs(schema as any, { auxiliaryTypeStore, unrepresentable: 'any' }); - return printNode(node); - } catch (error) - { - console.error(error); - return e; - } - } - return e; - }); - })); - - const pkg = { - ...sdkPackage, - license: appPkg.license, - version: appPkg.version, - repository: appPkg.repository, - author: appPkg.author, - peerDependencies: appPkg.dependencies - }; - await Bun.write(path.join(outDir, 'package.json'), JSON.stringify(pkg, null, 3)); - await fs.cp('./scripts/sdk/README.md', path.join(outDir, 'README.md')); -} - -await generateApiDeclarations(); \ No newline at end of file diff --git a/scripts/sdk/package.json b/scripts/sdk/package.json deleted file mode 100644 index 17d855e..0000000 --- a/scripts/sdk/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@simeonradivoev/gameflow-sdk", - "types": "index.d.ts", - "description": "plugin SDK for the Gameflow Deck Launcher", - "keywords": [ - "gameflow", - "sdk" - ] -} \ No newline at end of file diff --git a/scripts/sdk/sdk.ts b/scripts/sdk/sdk.ts deleted file mode 100644 index 6568653..0000000 --- a/scripts/sdk/sdk.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { SettingsType } from '@/shared/constants'; -import Conf from 'conf'; -import { AppEventMap } from '../../src/bun/types/types'; -import EventEmitter from "node:events"; -import { TaskQueue } from '@/bun/api/task-queue'; - -export * from '../../src/bun/types/types.schema'; -export * from '../../src/bun/types/types'; -export * from '../../src/bun/api/hooks/app'; -export * from '../../src/shared/constants'; -export * from '../../src/shared/types'; -export * from '../../src/shared/utils'; - -export declare const config: Conf; -export declare let events: EventEmitter; -export declare let taskQueue: TaskQueue; - -export { }; \ No newline at end of file diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index db06c81..68ec287 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -1,5 +1,5 @@ -import { TaskQueue } from "./task-queue"; +import { TaskQueue, AppEventMap } from "@simeonradivoev/gameflow-sdk"; import { Database } from "bun:sqlite"; import { CookieJar } from 'tough-cookie'; import FileCookieStore from 'tough-cookie-file-store'; @@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import Conf from "conf"; import projectPackage from '~/package.json'; -import { SettingsSchema, SettingsType } from "@shared/constants"; +import { SettingsType, SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { client } from "@clients/romm/client.gen"; import * as schema from "@schema/app"; import cacheSchema from "@schema/cache"; @@ -24,7 +24,6 @@ import controls from './controls/controls'; import { RunAPIServer } from "./rpc"; import { RunBunServer } from "../server"; import ReloadPluginsJob from "./jobs/reload-plugins-job"; -import { AppEventMap } from "../types/types"; export let config: Conf; export let customEmulators: Conf>; diff --git a/src/bun/api/cache.ts b/src/bun/api/cache.ts index cdad6b1..04abd1e 100644 --- a/src/bun/api/cache.ts +++ b/src/bun/api/cache.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { cache } from "./app"; import cacheSchema from "@schema/cache"; -import { GithubReleaseSchema } from "@/shared/constants"; +import { GithubReleaseSchema } from '@simeonradivoev/gameflow-sdk/shared'; import PQueue from "p-queue"; import z from "zod"; @@ -11,7 +11,8 @@ export const CACHE_KEYS = { STORE_GAME_MANIFEST: 'store-game-manifest' } as const; -export const githubRequestQueue = new PQueue({ intervalCap: 10, interval: 1000 * 60 * 10, strict: true }); +// we aggressively cache github data so burst of calls is fine. +export const githubRequestQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60 * 60, strict: true }); export async function getOrCached (key: string, getter: (lastValue: T | undefined) => Promise, options?: { expireMs?: number; force?: boolean; }): Promise { diff --git a/src/bun/api/drives.ts b/src/bun/api/drives.ts index a7f0565..99452d8 100644 --- a/src/bun/api/drives.ts +++ b/src/bun/api/drives.ts @@ -1,7 +1,7 @@ import si from 'systeminformation'; import fs from 'node:fs'; import os from "node:os"; -import { Drive } from '@/shared/types'; +import { Drive } from '@simeonradivoev/gameflow-sdk/shared'; async function getAccess (path: string) { diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index d770a43..d733d34 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -5,7 +5,7 @@ import z from "zod"; import path from 'node:path'; import { config, events, plugins } from "../app"; import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService"; -import { SaveFileChange } from "@/shared/types"; +import { SaveFileChange } from "@simeonradivoev/gameflow-sdk/shared"; // TODO: use the retroarch cores based on ES-DE export const cores: Record = { diff --git a/src/bun/api/games/collections.ts b/src/bun/api/games/collections.ts index 1a49a12..ae31430 100644 --- a/src/bun/api/games/collections.ts +++ b/src/bun/api/games/collections.ts @@ -1,6 +1,6 @@ import Elysia, { status } from "elysia"; import { plugins } from "../app"; -import { FrontEndCollection } from "@/shared/types"; +import { FrontEndCollection } from "@simeonradivoev/gameflow-sdk/shared"; export default new Elysia() .get('/collections', async () => diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 1b73e78..3c2575c 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -4,7 +4,8 @@ import { and, desc, eq, getTableColumns, inArray, like, sql } from "drizzle-orm" import z from "zod"; import * as schema from "@schema/app"; import fs from "node:fs/promises"; -import { GameListFilterSchema, SERVER_URL } from "@shared/constants"; +import { SERVER_URL } from "@shared/constants"; +import { GameListFilterSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { InstallJob } from "../jobs/install-job"; import path from "node:path"; import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; @@ -22,7 +23,7 @@ import { LaunchGameJob } from "../jobs/launch-game-job"; import { cores } from "../emulatorjs/emulatorjs"; import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; import { ImportJob } from "../jobs/import-job"; -import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@/shared/types"; +import { EmulatorSourceEntryType, EmulatorSystem, FrontEndFilterLists, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailedEmulator, FrontEndGameTypeWithIds, FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; // A custom jimp that supports webp const Jimp = createJimp({ diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index de888ba..10aaf42 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -4,7 +4,7 @@ import { and, count, eq, getTableColumns, not, notExists, or } from "drizzle-orm import { config, db, plugins } from "../app"; import * as schema from "@schema/app"; import { findPlatform } from "./services/utils"; -import { FrontEndPlatformType } from "@/shared/types"; +import { FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared"; export default new Elysia() .get('/platforms', async () => diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 248d6f2..490850d 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -6,7 +6,7 @@ import { config, taskQueue } from '../../app'; import { LaunchGameJob } from '../../jobs/launch-game-job'; import { getStoreEmulatorPackage } from '../../store/services/gamesService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; -import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@/shared/types'; +import { CommandEntry, EmulatorSourceEntryType, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared'; export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string) { diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 05bade3..1eaed5b 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -8,9 +8,10 @@ import z from "zod"; import { InstallJob, InstallJobStates } from "../../jobs/install-job"; import { LaunchGameJob } from "../../jobs/launch-game-job"; import * as appSchema from "@schema/app"; -import { DownloadSourceSchema, RPC_URL } from "@/shared/constants"; +import { RPC_URL } from "@/shared/constants"; +import { DownloadSourceSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { host } from "@/bun/utils/host"; -import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@/shared/types"; +import { CommandEntry, FrontEndId, GameLookup, GameStatusType, LocalDownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared"; export class CommandSearchError extends Error { @@ -115,11 +116,15 @@ export async function update (source: string, id: string) const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; if (paths_screenshots.length <= 0 && sourceGame.igdb_id) { - const matches: GameLookup[] = []; - await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id), matches }); - if (matches.length > 0) + const matches = new Map(); + await plugins.hooks.games.gameLookup.promise(matches, { source: 'igdb', id: String(sourceGame.igdb_id) }); + if (matches.size > 0) { - paths_screenshots.push(...matches[0].screenshotUrls); + const firstMatches = matches.values().next().value; + if (firstMatches && firstMatches.length > 0) + { + paths_screenshots.push(...firstMatches[0].screenshotUrls); + } } } @@ -244,7 +249,31 @@ export async function getValidLaunchCommandsForGame (source: string, id: string) commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, - sourceId: String(localGame.source_id) ?? id, + sourceId: localGame.source_id ? String(localGame.source_id) : id, + }; + } + else + { + return new CommandSearchError('missing-emulator', `Missing One Of Emulators: ${Array.from(new Set(commands.filter(e => e.emulator && e.emulator !== "OS-SHELL").map(e => e.emulator))).join(', ')}`); + } + } else if (source === 'emulator') + { + const commands = await plugins.hooks.games.buildLaunchCommands.promise({ + source, + sourceId: id, + id: { source: source, id: id }, + systemSlug: "", + gamePath: null + }); + + if (commands instanceof Error || !commands) return commands; + + const validCommand = commands.find(c => c.valid); + if (validCommand) + { + return { + commands: commands.filter(c => c.valid), + gameId: { id, source } }; } else diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index fd4b2d9..aaac97b 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -8,7 +8,7 @@ import { RPC_URL } from "@shared/constants"; import { hashFile } from "@/bun/utils"; import { host } from "@/bun/utils/host"; import * as emulatorSchema from "@schema/emulators"; -import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@/shared/types"; +import { DownloadFileEntry, FrontEndGameType, FrontEndGameTypeDetailed, GameLookup, LocalDownloadFileEntry, LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared"; export async function calculateSize (installPath: string | null) { diff --git a/src/bun/api/jobs/bios-download-job.ts b/src/bun/api/jobs/bios-download-job.ts index be46c5f..64537c1 100644 --- a/src/bun/api/jobs/bios-download-job.ts +++ b/src/bun/api/jobs/bios-download-job.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import { config, plugins } from "../app"; import { simulateProgress } from "@/bun/utils"; import { Downloader } from "@/bun/utils/downloader"; diff --git a/src/bun/api/jobs/emulator-download-job.ts b/src/bun/api/jobs/emulator-download-job.ts index 7dbaf6e..9aa35a6 100644 --- a/src/bun/api/jobs/emulator-download-job.ts +++ b/src/bun/api/jobs/emulator-download-job.ts @@ -1,6 +1,6 @@ -import { EmulatorPackageType } from "@/shared/constants"; +import { EmulatorPackageType } from '@simeonradivoev/gameflow-sdk/shared'; import { getStoreEmulatorPackage } from "../store/services/gamesService"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import z from "zod"; import { config, plugins } from "../app"; import path from 'node:path'; @@ -12,7 +12,7 @@ import { simulateProgress } from "@/bun/utils"; import { path7za } from "7zip-bin"; import { getEmulatorDownload, getEmulatorPath } from "../store/services/emulatorsService"; import { $ } from "bun"; -import { EmulatorSourceEntryType } from "@/shared/types"; +import { EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared"; type EmulatorDownloadStates = "download" | "extract"; diff --git a/src/bun/api/jobs/import-job.ts b/src/bun/api/jobs/import-job.ts index 3e608a4..f1d25be 100644 --- a/src/bun/api/jobs/import-job.ts +++ b/src/bun/api/jobs/import-job.ts @@ -1,10 +1,10 @@ import { eq, or } from "drizzle-orm"; import { db, plugins } from "../app"; import { createLocalGame } from "../games/services/utils"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import * as schema from "@schema/app"; import z from "zod"; -import { GameLookup } from "@/shared/types"; +import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; export class ImportJob implements IJob, string> { diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index b6809e2..a9433d4 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -1,4 +1,4 @@ -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import fs from 'node:fs/promises'; import path from 'node:path'; import { config, events, plugins } from "../app"; @@ -11,7 +11,7 @@ import { ensureDir, move } from "fs-extra"; import { path7za } from "7zip-bin"; import StreamZip from 'node-stream-zip'; import { which } from "bun"; -import { DownloadInfo } from "@/shared/types"; +import { DownloadInfo } from "@simeonradivoev/gameflow-sdk/shared"; interface JobConfig { diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 328c04e..f75605c 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -6,7 +6,7 @@ import TwitchLoginJob from "./twitch-login-job"; import UpdateStoreJob from "./update-store"; import { EmulatorDownloadJob } from "./emulator-download-job"; import { getErrorMessage } from "@/bun/utils"; -import { IJob } from "../task-queue"; +import { IJob } from "../../../packages/gameflow-sdk/task-queue"; import { LaunchGameJob } from "./launch-game-job"; import { BiosDownloadJob } from "./bios-download-job"; import { InstallJob } from "./install-job"; diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index d81c25c..f5072e9 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -1,13 +1,13 @@ import z from "zod"; -import { IJob, JobContext } from "../task-queue"; -import { ActiveGameSchema, ActiveGameType } from "@/bun/types/types.schema"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; +import { ActiveGameSchema, ActiveGameType } from "@simeonradivoev/gameflow-sdk"; import { config, db, events, plugins } from "../app"; import * as appSchema from "@schema/app"; import { eq } from "drizzle-orm"; import { spawn } from 'node:child_process'; import { updateLocalLastPlayed } from "../games/services/statusService"; import { getErrorMessage } from "@/bun/utils"; -import { CommandEntry, FrontEndId, SaveSlots } from "@/shared/types"; +import { CommandEntry, FrontEndId, SaveSlots } from "@simeonradivoev/gameflow-sdk/shared"; export class LaunchGameJob implements IJob, string> { diff --git a/src/bun/api/jobs/login-job.ts b/src/bun/api/jobs/login-job.ts index f0726bd..dd112ad 100644 --- a/src/bun/api/jobs/login-job.ts +++ b/src/bun/api/jobs/login-job.ts @@ -1,5 +1,5 @@ import Elysia, { status } from "elysia"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "../../../packages/gameflow-sdk/task-queue"; import { LOGIN_PORT, SERVER_URL } from "@/shared/constants"; import { host, localIp } from "@/bun/utils/host"; import cors from "@elysiajs/cors"; diff --git a/src/bun/api/jobs/plugin-operation-job.ts b/src/bun/api/jobs/plugin-operation-job.ts new file mode 100644 index 0000000..db39819 --- /dev/null +++ b/src/bun/api/jobs/plugin-operation-job.ts @@ -0,0 +1,62 @@ +import z from "zod"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; +import { plugins } from "../app"; +import { canUninstall, runBunPackageCommand } from "../plugins/services"; +import { getPlugin, registerPlugin, unregisterPlugin } from "../plugins/register-plugins"; +import { PluginRegistry } from "@/shared/constants"; + +export default class PluginOperationJob implements IJob +{ + static id = "plugin-operation-job" as const; + static dataSchema = z.never(); + group = "plugin-operations"; + operation: "add" | "update" | "remove"; + plugin: string; + + constructor(operation: "add" | "update" | "remove", plugin: string) + { + this.plugin = plugin; + this.operation = operation; + } + + async start (context: JobContext, never, string>) + { + switch (this.operation) + { + case "add": + //TODO: find the latest compatible version with the current sdk version + const addResponse = await runBunPackageCommand(["add", this.plugin, '--omit', 'peer', "--registry", PluginRegistry]); + console.log(addResponse); + const addPlugin = await getPlugin(this.plugin, plugins); + if (!addPlugin) throw new Error(`${this.plugin} Not Found`); + await registerPlugin(addPlugin, 'store', plugins); + break; + case "update": + const existingPlugin = plugins.plugins[this.plugin]; + if (!existingPlugin) throw new Error(`${this.plugin} Not Found`); + if (!existingPlugin.update?.new) throw new Error(`No Update Found`); + let updatePlugin = await getPlugin(this.plugin, plugins); + if (!updatePlugin) throw new Error(`${this.plugin} Not Found`); + await unregisterPlugin(this.plugin, plugins); + const updateResponse = await runBunPackageCommand(["update", `${this.plugin}@${existingPlugin.update?.new}`, '--omit', 'peer', "--registry", PluginRegistry, '--latest']); + console.log(updateResponse); + updatePlugin = await getPlugin(this.plugin, plugins); + if (!updatePlugin) throw new Error(`Something Went Wrong during update. Missing Plugin: ${this.plugin}`); + await registerPlugin(updatePlugin, existingPlugin.source, plugins); + break; + case "remove": + const removePlugin = plugins.plugins[this.plugin]; + if (!removePlugin) throw new Error(`${this.plugin} Not Found`); + if (!canUninstall(removePlugin.description, removePlugin.source)) + { + throw new Error("Uninstall Not Allowed"); + } + const response = await runBunPackageCommand(['remove', this.plugin, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + await unregisterPlugin(this.plugin, plugins); + break; + } + + + } +} \ No newline at end of file diff --git a/src/bun/api/jobs/reload-plugins-job.ts b/src/bun/api/jobs/reload-plugins-job.ts index 4796fc8..5e404d3 100644 --- a/src/bun/api/jobs/reload-plugins-job.ts +++ b/src/bun/api/jobs/reload-plugins-job.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import { plugins } from "../app"; export default class ReloadPluginsJob implements IJob diff --git a/src/bun/api/jobs/self-update-job.ts b/src/bun/api/jobs/self-update-job.ts index 05ac4e6..ca2684e 100644 --- a/src/bun/api/jobs/self-update-job.ts +++ b/src/bun/api/jobs/self-update-job.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import { events } from "../app"; import { Downloader } from "@/bun/utils/downloader"; import path from 'node:path'; diff --git a/src/bun/api/jobs/twitch-login-job.ts b/src/bun/api/jobs/twitch-login-job.ts index 1023e83..42d98a9 100644 --- a/src/bun/api/jobs/twitch-login-job.ts +++ b/src/bun/api/jobs/twitch-login-job.ts @@ -1,4 +1,4 @@ -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import secrets from "../secrets"; import open from "open"; import z from "zod"; diff --git a/src/bun/api/jobs/update-store.ts b/src/bun/api/jobs/update-store.ts index 48129a0..697fb3a 100644 --- a/src/bun/api/jobs/update-store.ts +++ b/src/bun/api/jobs/update-store.ts @@ -1,59 +1,57 @@ import { ensureDir } from "fs-extra"; -import { IJob, JobContext } from "../task-queue"; +import { IJob, JobContext } from "@simeonradivoev/gameflow-sdk"; import { getStoreRootFolder } from "../store/services/gamesService"; -import { tmpdir } from "node:os"; -import path from "node:path"; import z from "zod"; +import { runBunPackageCommand } from "../plugins/services"; +import { PluginRegistry } from "@/shared/constants"; +import path from "node:path"; +import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json'; -export default class UpdateStoreJob implements IJob +export default class UpdateStoreJob implements IJob { static id = "update-store" as const; static dataSchema = z.never(); packageName: string; - registry: URL; storeVersion: string; constructor() { this.packageName = process.env.STORE_PACKAGE_NAME ?? "@simeonradivoev/gameflow-store"; - this.registry = new URL(process.env.STORE_REGISTRY ?? "https://registry.npmjs.org"); this.storeVersion = process.env.STORE_VERSION ?? "^0.1.0"; } - async runCommand (commands: string[]) + async start (context: JobContext) { - const tempCache = path.join(tmpdir(), "gameflow-bun-cache"); - const storeFolder = getStoreRootFolder(); - - let proc = Bun.spawn([process.execPath, ...commands, "--registry", this.registry.href, '--json'], { - cwd: storeFolder, - stdout: 'pipe', - stderr: 'pipe', - env: { - BUN_BE_BUN: "1", - BUN_INSTALL_CACHE_DIR: tempCache - } - }); - - let stdout = await new Response(proc.stdout).text(); - console.log(stdout); - let stderr = await new Response(proc.stderr).text(); - if (stderr) - console.error(stderr); - await proc.exited; - } - - async start (context: JobContext) - { - if (process.env.CUSTOM_STORE_PATH) return; - const storeFolder = getStoreRootFolder(); await ensureDir(storeFolder); + const storePackageFile = Bun.file(path.join(storeFolder, "package.json")); + if (!await storePackageFile.exists()) + { + await storePackageFile.write(JSON.stringify({ dependencies: {} }, null, 3)); + } - console.log("Adding Store Package"); - await this.runCommand(["add", `${this.packageName}@${this.storeVersion}`]); + const storePackage = await Bun.file(path.join(storeFolder, "package.json")).json(); - console.log("Updating Store Package"); - await this.runCommand(["update", `${this.packageName}@${this.storeVersion}`]); + if (!storePackage.dependencies?.[sdkPkg.name] || storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) + { + let response = await runBunPackageCommand(["add", `${sdkPkg.name}@${sdkPkg.version}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + + // probably just means we couldn't find a version of the sdk, just install latest + if (storePackage.dependencies?.[sdkPkg.name] !== sdkPkg.version) + { + let response = await runBunPackageCommand(["add", '--exact', `${sdkPkg.name}@latest`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } + + if (process.env.CUSTOM_STORE_PATH) return; + + if (!storePackage.dependencies?.['@simeonradivoev/gameflow-store']) + { + context.setProgress(0.5, "Adding Store"); + let response = await runBunPackageCommand(["add", `${this.packageName}@${this.storeVersion}`, "--registry", PluginRegistry, '--omit', 'peer']); + console.log(response); + } } } \ No newline at end of file diff --git a/src/bun/api/notifications.ts b/src/bun/api/notifications.ts index 514ee58..e1c135c 100644 --- a/src/bun/api/notifications.ts +++ b/src/bun/api/notifications.ts @@ -1,5 +1,5 @@ -import { FrontendNotification } from '@/shared/types'; +import { FrontendNotification } from '@simeonradivoev/gameflow-sdk/shared'; import { events } from './app'; export default function buildNotificationsStream () diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index 9fe34a4..a9e6865 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import path from 'node:path'; import { config } from "@/bun/api/app"; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index 111b9c5..6a44901 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -1,6 +1,6 @@ import { config } from "@/bun/api/app"; -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import path from 'node:path'; import desc from './package.json'; import { ensureDir } from "fs-extra"; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index 23c2736..58d61aa 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -1,12 +1,12 @@ import { config } from "@/bun/api/app"; -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; import desc from './package.json'; import ini from 'ini'; -import { EmulatorCapabilities } from "@/shared/types"; +import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared"; export default class PCSX2Integration implements PluginType { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index 87886dd..f69fdaf 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config } from "@/bun/api/app"; import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' }; @@ -11,7 +11,7 @@ import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; import ini from 'ini'; import fs from 'node:fs/promises'; -import { EmulatorCapabilities } from "@/shared/types"; +import { EmulatorCapabilities } from "@simeonradivoev/gameflow-sdk/shared"; export default class PPSSPPIntegration implements PluginType { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts index ff8c3f9..57506de 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config } from "@/bun/api/app"; import path from "node:path"; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts index eb0715d..9d37d99 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -1,6 +1,6 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; -import GameflowHooks from "@/bun/api/hooks/app"; +import { GameflowHooks } from "@simeonradivoev/gameflow-sdk"; import { config } from "@/bun/api/app"; import path from "node:path"; import { ensureDir } from "fs-extra"; diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts index cf57e13..77cd201 100644 --- a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app"; import * as emulatorSchema from '@schema/emulators'; @@ -13,7 +13,7 @@ import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameServic import { which } from "bun"; import os from 'node:os'; import { getLocalGameMatch } from "@/bun/api/games/services/utils"; -import { CommandEntry, EmulatorSourceEntryType } from "@/shared/types"; +import { CommandEntry, EmulatorSourceEntryType } from "@simeonradivoev/gameflow-sdk/shared"; export default class IgdbIntegration implements PluginType { diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts index a31c94d..8ab31a0 100644 --- a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { config, db, events } from "@/bun/api/app"; import path from 'node:path'; diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts index 7c39e01..c2be6d3 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -1,10 +1,10 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import secrets from "@/bun/api/secrets"; import PQueue from 'p-queue'; import * as igdb from '@phalcode/ts-igdb-client'; import { checkLoginAndRefreshTwitch } from "@/bun/api/auth"; -import { GameLookup } from "@/shared/types"; +import { GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; export default class IgdbIntegration implements PluginType { diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index ac9474f..93c6fbe 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -1,6 +1,6 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, PlatformSchema, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config, events } from "@/bun/api/app"; @@ -14,7 +14,7 @@ import { client } from "@/clients/romm/client.gen"; import { validateGameSource } from "@/bun/api/games/services/statusService"; import z from "zod"; import { checkLoginAndRefreshRomm } from "@/bun/api/auth"; -import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@/shared/types"; +import { DownloadFileEntry, DownloadInfo, FrontEndCollection, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement, FrontEndGameTypeWithIds, FrontEndPlatformType } from "@simeonradivoev/gameflow-sdk/shared"; import Conf from "conf"; const SettingsSchema = z.object({ diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json index 713f76f..644c332 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json @@ -1,8 +1,8 @@ { "name": "com.simeonradivoev.gameflow.store", - "displayName": "Gameflow Store", + "displayName": "Gameflow Store Integration", "version": "0.0.1", - "description": "The internal gameflow store", + "description": "The internal gameflow store integration. This is the logic of the store that uses the data only store package", "main": "./store.ts", "category": "sources", "canDisable": false, diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts index de08c46..17c5f12 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -1,5 +1,4 @@ import { getStoreFolder } from "@/bun/api/store/services/gamesService"; -import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; import os from 'node:os'; import path from "node:path"; import * as appSchema from '@schema/app'; @@ -12,7 +11,7 @@ import { shuffleInPlace } from "@/bun/utils"; import mustache from "mustache"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import fs from "node:fs/promises"; -import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange } from "@/shared/types"; +import { CommandEntry, EmulatorSourceEntryType, EmulatorSystem, FrontEndEmulator, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, SaveFileChange, EmulatorDownloadInfoType, StoreDownloadType, StoreGameType, EmulatorPackageType, EmulatorDownloadInfoSchema, StoreGameSchema } from "@simeonradivoev/gameflow-sdk/shared"; export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) { diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts index 3215548..9b514a2 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -1,4 +1,4 @@ -import { PluginLoadingContextType, PluginType } from "@/bun/types/types.schema"; +import { PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; import desc from './package.json'; import path, { } from 'node:path'; import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; @@ -12,7 +12,7 @@ import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; import UpdateStoreJob from "@/bun/api/jobs/update-store"; import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; import { buildFilters, buildLaunchCommand, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownloads } from "./services"; -import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@/shared/types"; +import { DownloadInfo, FrontEndEmulatorDetailed, FrontEndGameTypeWithIds } from "@simeonradivoev/gameflow-sdk/shared"; export default class RommIntegration implements PluginType { @@ -151,7 +151,8 @@ export default class RommIntegration implements PluginType if (!validDownload || !validDownload.bin) return; const glob = new Glob(validDownload.bin); const files = await Array.fromAsync(glob.scan({ cwd: emulatorPath })); - if (files.length > 0) + // es-de also searches for store executables so there might be duplicates, check first. + if (files.length > 0 && !sources.find(s => s.type === 'store')) { sources.push({ binPath: path.join(emulatorPath, files[0]), exists: true, rootPath: emulatorPath, type: 'store' }); } diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index e3511b5..1fab907 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -1,10 +1,13 @@ -import GameflowHooks from "../hooks/app"; -import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/types.schema"; -import { config } from "../app"; +import { GameflowHooks } from "@simeonradivoev/gameflow-sdk"; +import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "@simeonradivoev/gameflow-sdk"; +import { config, events, taskQueue } from "../app"; import Conf from "conf"; import projectPackage from '~/package.json'; import z from "zod"; -import { PluginSourceType } from "@/shared/types"; +import { PluginSourceType, PluginUpdateCheck } from "@simeonradivoev/gameflow-sdk/shared"; +import { getUpdates } from "./services"; +import sdkPkg from '@simeonradivoev/gameflow-sdk/package.json'; +import { semver } from "bun"; export const pluginZodRegistry = z.registry<{ requiresRestart?: boolean; @@ -21,9 +24,19 @@ export class PluginManager description: PluginDescriptionType, source: PluginSourceType; config?: Conf; + update?: PluginUpdateCheck; + incompatible?: boolean; }> = {}; + unregister (id: string) + { + if (!this.plugins[id]) return false; + delete this.plugins[id]; + console.log("Plugin", id, "unregistered"); + return true; + } + register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) { try @@ -68,16 +81,33 @@ export class PluginManager }; } - private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }) + checkValidity (plugin: PluginDescriptionType) + { + const sdkDep = plugin.peerDependencies?.[sdkPkg.name]; + if (sdkDep) + { + return semver.satisfies(sdkPkg.version, sdkDep); + } + return true; + } + + private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }, update: string | undefined) { const plugin = this.plugins[name]; if (plugin) { + plugin.update = update && !semver.satisfies(plugin.description.version, update) ? { current: plugin.description.version, new: update } : undefined; + const ctx: PluginLoadingContextType = { hooks: this.hooks, setProgress: reloadCtx.setProgress.bind(reloadCtx), config: plugin.config as any, - zodRegistry: pluginZodRegistry + zodRegistry: pluginZodRegistry, + app: { + config, + events, + taskQueue + } }; if (plugin.loaded) @@ -88,7 +118,14 @@ export class PluginManager try { - if (plugin.enabled || plugin.description.canDisable === false) + plugin.incompatible = !this.checkValidity(plugin.description); + if (plugin.incompatible) + { + console.error(plugin.description.name, "Incompatible sdk verison"); + return; + } + + if (plugin.enabled || plugin.description.canDisable === false || plugin.description.name === '@simeonradivoev/gameflow-store') { console.log("Loading Plugin", plugin.description.name); await plugin.plugin.load(ctx); @@ -106,10 +143,13 @@ export class PluginManager async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; }) { this.hooks = new GameflowHooks(); + + const outdated = await getUpdates(); + for await (const id of Object.keys(this.plugins)) { ctx.setProgress(0, `Loading ${id}`); - await this.reload(id, ctx); + await this.reload(id, ctx, outdated?.[id]); } } diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts index eed9466..ddfad06 100644 --- a/src/bun/api/plugins/plugins.ts +++ b/src/bun/api/plugins/plugins.ts @@ -3,7 +3,9 @@ import { plugins, taskQueue } from "../app"; import z from "zod"; import { toggleElementInConfig } from "@/bun/utils"; import ReloadPluginsJob from "../jobs/reload-plugins-job"; -import { FrontendPlugin } from "@/shared/types"; +import { FrontendPlugin } from "@simeonradivoev/gameflow-sdk/shared"; +import { canDisable, canUninstall } from "./services"; +import PluginOperationJob from "../jobs/plugin-operation-job"; export default new Elysia({ prefix: '/plugins' }) .get('/', async () => @@ -17,25 +19,27 @@ export default new Elysia({ prefix: '/plugins' }) description: p.description.description, source: p.source, version: p.description.version, - canDisable: p.description.canDisable ?? true, + canDisable: canDisable(p.description), icon: p.description.icon, category: p.description.category, - hasSettings: !!p.config || !!p.plugin.eventsNames + hasSettings: !!p.config || !!p.plugin.eventsNames, + canUninstall: canUninstall(p.description, p.source), + update: p.update }; return plugin; }); }) .get('/:id', async ({ params: { id } }) => { - const plugin = plugins.plugins[id]; - return plugin.description; + const plugin = plugins.plugins[decodeURIComponent(id)]; + return { ...plugin.description, update: plugin.update }; }) .post('/:id', async ({ params: { id }, body: { enabled } }) => { - const plugin = plugins.plugins[id]; + const plugin = plugins.plugins[decodeURIComponent(id)]; if (plugin) { - if (plugin.description.canDisable === false) + if (!canDisable(plugin.description)) { return status("Forbidden"); } @@ -48,4 +52,26 @@ export default new Elysia({ prefix: '/plugins' }) } }, { body: z.object({ enabled: z.boolean() }) + }).post('/install', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("add", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) + }).post('/update', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("update", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) + }) + .post('/uninstall', async ({ body: { id } }) => + { + if (taskQueue.hasActiveOfType(PluginOperationJob) || taskQueue.hasActiveOfType(ReloadPluginsJob)) return; + await taskQueue.enqueue(PluginOperationJob.id, new PluginOperationJob("remove", id)); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + }, { + body: z.object({ id: z.string() }) }); \ No newline at end of file diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index ead6f54..1275740 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -11,12 +11,78 @@ import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.jso import store from './builtin/sources/com.simeonradivoev.gameflow.store/package.json'; import es from './builtin/launchers/com.simeonradivoev.gameflow.es/package.json'; import rclone from './builtin/other/com.simeonradivoev.gameflow.rclone/package.json'; -import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/types.schema"; +import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@simeonradivoev/gameflow-sdk"; import path from 'node:path'; import { getStoreRootFolder } from "../store/services/gamesService"; +import { getUpdates } from "./services"; +import { PluginSourceType } from "@simeonradivoev/gameflow-sdk/shared"; +import { taskQueue } from "../app"; +import UpdateStoreJob from "../jobs/update-store"; type PluginEntry = PluginDescriptionType & { load: () => Promise; }; +const blacklist = new Set(['@simeonradivoev/gameflow-sdk']); + +export async function getPlugin (id: string, pluginManager: PluginManager) +{ + const pluginPath = path.join(getStoreRootFolder(), 'node_modules', id); + const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json')); + if (await pluginPackageFile.exists()) + { + const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json()); + if (pluginPackage.success) + { + const mainPath = path.join(pluginPath, pluginPackage.data.main); + if (await Bun.file(mainPath).exists()) + { + const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) }; + return entry; + } else + { + console.error("Main file for", id, "does not exist"); + } + } else + { + console.error("Invalid Package for", id, pluginPackage.error.message); + } + } else + { + console.error("Package for", id, "does not exist"); + } +} + +export async function unregisterPlugin (id: string, pluginManager: PluginManager) +{ + return pluginManager.unregister(id); +} + +export async function registerPlugin (plugin: PluginEntry, source: PluginSourceType, pluginManager: PluginManager) +{ + if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(plugin.name)) + { + console.log("Skipping", plugin.name, "missing in whitelist"); + return; + } + + if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(plugin.name)) + { + console.log("Skipping", plugin.name, "found in whitelist"); + return; + } + + const file = await plugin.load(); + if (file.default && typeof file.default === 'function') + { + const pluginInstance = new file.default(); + await PluginSchema.parseAsync(pluginInstance); + const description = await PluginDescriptionSchema.parseAsync(plugin); + pluginManager.register(pluginInstance, description, source); + } else + { + console.log("Skipping", plugin.name, "invalid main. Has to be class with load method"); + } +} + export default async function register (pluginManager: PluginManager) { const plugins: PluginEntry[] = [ @@ -33,53 +99,41 @@ export default async function register (pluginManager: PluginManager) { ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') }, ]; - const storePackageFile = path.join(getStoreRootFolder(), 'package.json'); - const storePackage = await Bun.file(storePackageFile).json(); + await Promise.all(plugins.map(p => registerPlugin(p, 'builtin', pluginManager))); - if (storePackage.dependencies) + const storePackageFilePath = path.join(getStoreRootFolder(), 'package.json'); + if (!await Bun.file(storePackageFilePath).exists()) { - const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).map(async p => + console.log("Store is missing. Updating it."); + await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + console.log("Store Updated"); + } + const storePackage = await Bun.file(storePackageFilePath).json(); + + if (storePackage?.dependencies) + { + const storePlugins = await Promise.all(Object.keys(storePackage.dependencies).filter(p => !blacklist.has(p)).map(async p => { - const pluginPath = path.join(getStoreRootFolder(), 'node_modules', p); - const pluginPackageFile = Bun.file(path.join(pluginPath, 'package.json')); - if (await pluginPackageFile.exists()) - { - const pluginPackage = await PluginDescriptionSchema.safeParseAsync(await pluginPackageFile.json()); - if (pluginPackage.success) - { - const mainPath = path.join(pluginPath, pluginPackage.data.main); - if (await Bun.file(mainPath).exists()) - { - const entry: PluginEntry = { ...pluginPackage.data, load: () => import(mainPath) }; - return entry; - } - } - } + return getPlugin(p, pluginManager); })); - plugins.push(...storePlugins.filter(p => !!p)); - } + console.log("Checking for outdated packages"); + const outdated = await getUpdates(); - await Promise.all(plugins.filter(p => - { - if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name)) + const validPlugins = storePlugins.filter(p => !!p); + + if (outdated) { - return false; + validPlugins.forEach(p => + { + const newVersion = outdated[p.name]; + if (newVersion) + { + console.log("Plugin", p.name, "has update", p.version, "=>", newVersion); + } + }); } - if (process.env.PLUGIN_BLACKLIST && process.env.PLUGIN_BLACKLIST.includes(p.name)) - { - return false; - } - return true; - }).map(async (pluginPackage) => - { - const file = await pluginPackage.load(); - if (file.default && typeof file.default === 'function') - { - const pluginInstance = new file.default(); - await PluginSchema.parseAsync(pluginInstance); - const description = await PluginDescriptionSchema.parseAsync(pluginPackage); - pluginManager.register(pluginInstance, description, 'builtin'); - } - })); + + await Promise.all(validPlugins.map(p => registerPlugin(p, 'store', pluginManager))); + } } \ No newline at end of file diff --git a/src/bun/api/plugins/services.ts b/src/bun/api/plugins/services.ts new file mode 100644 index 0000000..9452b7e --- /dev/null +++ b/src/bun/api/plugins/services.ts @@ -0,0 +1,62 @@ +import path from 'node:path'; +import os from 'node:os'; +import { getStoreRootFolder } from '../store/services/gamesService'; +import { PluginDescriptionType } from '@simeonradivoev/gameflow-sdk'; +import { run } from 'npm-check-updates'; + +export function canDisable (description: PluginDescriptionType) +{ + if (description.name === '@simeonradivoev/gameflow-store') + { + return false; + } + return description.canDisable ?? true; +} + +export async function getUpdates () +{ + const updated = await run({ packageManager: 'bun', peer: true, cwd: getStoreRootFolder(), jsonUpgraded: true, reject: ['@simeonradivoev/gameflow-sdk'] }); + return updated as Record; +} + +export function canUninstall (description: PluginDescriptionType, source: string) +{ + if (description.name === '@simeonradivoev/gameflow-store') + { + return false; + } + return source !== 'builtin'; +} + +export async function runBunPackageCommand (commands: string[]) +{ + const tempCache = path.join(os.tmpdir(), "gameflow-bun-cache"); + const storeFolder = getStoreRootFolder(); + + let proc = Bun.spawn([process.execPath, ...commands, '--json'], { + cwd: storeFolder, + stdout: 'pipe', + stderr: 'pipe', + env: { + BUN_BE_BUN: "1", + BUN_INSTALL_CACHE_DIR: tempCache + } + }); + + let stdout = await new Response(proc.stdout).text(); + let stderr = await new Response(proc.stderr).text(); + if (stderr) + console.error(stderr); + await proc.exited; + return stdout; +} + +export async function hasPackage (id: string) +{ + const storeFolder = getStoreRootFolder(); + const packagePath = path.join(storeFolder, 'package.json'); + const packageFile = Bun.file(packagePath); + if (!await packageFile.exists()) return false; + const pkg = await packageFile.json(); + return !!pkg.dependencies?.[id]; +} \ No newline at end of file diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index a30c4fb..7db68ba 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -1,4 +1,5 @@ -import { LocalGameMetadata } from "@/shared/types"; + +import { LocalGameMetadata } from "@simeonradivoev/gameflow-sdk/shared"; import { sql, relations } from "drizzle-orm"; import { integer, text, sqliteTable, blob } from "drizzle-orm/sqlite-core"; diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index a560de6..e0897ea 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -7,7 +7,7 @@ import { cores } from '../emulatorjs/emulatorjs'; import { SERVER_URL } from '@/shared/constants'; import { host } from '@/bun/utils/host'; import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; -import { EmulatorSourceEntryType, FrontEndEmulator } from '@/shared/types'; +import { EmulatorSourceEntryType, FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; /** * Get emulators based on local games. Only the ones we probably need. diff --git a/src/bun/api/settings/settings.ts b/src/bun/api/settings/settings.ts index c315701..e4e2da1 100644 --- a/src/bun/api/settings/settings.ts +++ b/src/bun/api/settings/settings.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { SettingsSchema } from "@shared/constants"; +import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import Elysia, { status } from "elysia"; import { config, customEmulators, plugins, taskQueue } from "../app"; import fs from 'node:fs/promises'; @@ -96,27 +96,27 @@ export const settings = new Elysia({ prefix: '/api/settings' }) }) .get('/definitions/:source', async ({ params: { source } }) => { - return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7; + return plugins.plugins[decodeURIComponent(source)].plugin.settingsSchema?.toJSONSchema() as JSONSchema7; }) .get('/actions/:source', async ({ params: { source } }) => { - const plugin = plugins.plugins[source]?.plugin; + const plugin = plugins.plugins[decodeURIComponent(source)]?.plugin; if (!plugin.eventsNames) return []; return plugin.eventsNames; }) .post('/actions/:source/:id', async ({ params: { source, id } }) => { - return await plugins.plugins[source]?.plugin.onEvent?.(id); + return await plugins.plugins[decodeURIComponent(source)]?.plugin.onEvent?.(decodeURIComponent(id)); }) .get('/:source/:id', async ({ params: { source, id } }) => { - return { value: plugins.plugins[source].config?.get(id) }; + return { value: plugins.plugins[decodeURIComponent(source)].config?.get(decodeURIComponent(id)) }; }) .put('/:source/:id', async ({ params: { source, id }, body: { value } }) => { - const plugin = plugins.plugins[source]; + const plugin = plugins.plugins[decodeURIComponent(source)]; if (!plugin.config) return status("Not Found", "Plugin has no config"); - const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject; + const settingSchema = plugin.plugin.settingsSchema?.shape[decodeURIComponent(id)] as z.ZodObject; if (!settingSchema) return status("Not Found", "Could not find setting"); const meta = pluginZodRegistry.get(settingSchema); diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index c61ed00..6dfcde1 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,8 +1,7 @@ -import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; import { config, plugins } from "../../app"; import { getOrCached, getOrCachedGithubRelease } from "../../cache"; import path from "node:path"; -import { EmulatorSourceEntryType, EmulatorSupport } from "@/shared/types"; +import { EmulatorSourceEntryType, EmulatorSupport, ScoopPackageSchema, EmulatorPackageType, EmulatorDownloadInfoType } from "@simeonradivoev/gameflow-sdk/shared"; export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[] { diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index f2149ff..b475b89 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -1,10 +1,9 @@ -import { EmulatorPackageSchema, EmulatorPackageType } from "@/shared/constants"; import { and, eq, or } from "drizzle-orm"; import { config, emulatorsDb } from '../../app'; import path from "node:path"; import fs from 'node:fs/promises'; import * as emulatorSchema from '@schema/emulators'; -import { EmulatorSystem } from "@/shared/types"; +import { EmulatorSystem, EmulatorPackageType, EmulatorPackageSchema } from "@simeonradivoev/gameflow-sdk/shared"; export function getStoreRootFolder () { diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 85463b3..39d5630 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -3,7 +3,6 @@ import Elysia, { status } from "elysia"; import { config, db, plugins, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { EmulatorDownloadInfoSchema } from "@/shared/constants"; import * as appSchema from '@schema/app'; import z from "zod"; import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; @@ -13,7 +12,17 @@ import { getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; import { BiosDownloadJob } from "../jobs/bios-download-job"; import { findEmulatorPluginIntegration, getEmulatorPath } from "./services/emulatorsService"; -import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed } from "@/shared/types"; +import { EmulatorSourceEntryType, FrontEndEmulator, FrontEndGameTypeDetailed, PluginBunDetailsSchema, PluginEntrySchema, EmulatorDownloadInfoSchema } from "@simeonradivoev/gameflow-sdk/shared"; +import PQueue from "p-queue"; +import { hasPackage, runBunPackageCommand } from "../plugins/services"; +import { semver } from "bun"; + +const npmQueue = new PQueue({ intervalCap: 60, interval: 1000 * 60, strict: true }); +const pluginsResponseSchema = z.object({ + objects: z.array(PluginEntrySchema), + total: z.number(), + time: z.coerce.date() +}); export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -109,6 +118,49 @@ export const store = new Elysia({ prefix: '/api/store' }) gameCount }; }) + .get('/plugin', async ({ query: { plugin } }) => + { + const pluginsRes = await runBunPackageCommand(['info', plugin]); + const pluginData = await PluginBunDetailsSchema.parseAsync(JSON.parse(pluginsRes)); + const existingVersion = plugins.plugins[plugin]?.description.version; + + return { + ...pluginData, + installed: !!plugins.plugins[plugin] || await hasPackage(plugin), + update: existingVersion && semver.order(pluginData.version, existingVersion) > 0 ? { from: existingVersion } : undefined + }; + }, + { + query: z.object({ plugin: z.string() }) + }) + .get('/plugins', async ({ query: { search } }) => + { + //TODO: Find a better way to search keywords and a search term at the same time + const pluginsRes = await npmQueue.add(() => fetch(`https://registry.npmjs.com/-/v1/search?text=keywords:gameflow-plugin`)); + if (!pluginsRes.ok) return status(pluginsRes.status, pluginsRes.statusText); + const data: z.infer = await pluginsRes.json(); + if (search) + { + data.objects = data.objects.filter(o => + { + if (o.package.description && o.package.description.includes(search)) return true; + if (o.package.name.includes(search)) return true; + if (o.package.keywords.includes(search)) return true; + return false; + }); + data.total = data.objects.length; + } + await Promise.all(data.objects.map(async o => + { + const existingVersion = plugins.plugins[o.package.name]?.description.version; + o.installed = !!plugins.plugins[o.package.name] || await hasPackage(o.package.name); + o.update = existingVersion && semver.order(o.package.version, existingVersion) > 0 ? { from: existingVersion } : undefined; + })); + return data as any; + }, { + query: z.object({ search: z.string().optional() }), + response: pluginsResponseSchema + }) .get('/media/*', async ({ params }) => { return Bun.file(path.join(getStoreFolder(), params["*"])); diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 66e7742..5408a6d 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -7,7 +7,7 @@ import { getAppVersion, isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; import path, { dirname } from "node:path"; -import { DirSchema, SystemInfoSchema } from "@/shared/constants"; +import { SystemInfoSchema, DirSchema, DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared'; import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; @@ -16,7 +16,6 @@ import ReloadPluginsJob from "./jobs/reload-plugins-job"; import { semver } from "bun"; import { getOrCachedGithubRelease } from "./cache"; import SelfUpdateJob from "./jobs/self-update-job"; -import { DownloadsDrive } from "@/shared/types"; async function checkUpdate (force?: boolean) { diff --git a/src/bun/types/types.ts b/src/bun/types/types.ts deleted file mode 100644 index 6802ff9..0000000 --- a/src/bun/types/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EmulatorDownloadInfoType, EmulatorPackageType } from "@/shared/constants"; -import { FrontendNotification } from "@/shared/types"; - -export interface AppEventMap -{ - exitapp: []; - notification: [FrontendNotification]; - focus: []; -} - -export interface EmulatorPostInstallContext -{ - emulator: string; - emulatorPackage?: EmulatorPackageType; - path: string; - update: boolean; - info: EmulatorDownloadInfoType; -} \ No newline at end of file diff --git a/src/bun/utils.ts b/src/bun/utils.ts index f03a42c..fe44ad2 100644 --- a/src/bun/utils.ts +++ b/src/bun/utils.ts @@ -1,10 +1,9 @@ import { $, sleep } from 'bun'; import path from 'node:path'; -import { SettingsType } from '@/shared/constants'; +import { SettingsType, KeysWithValueAssignableTo } from '@simeonradivoev/gameflow-sdk/shared'; import { config } from './api/app'; import fs from 'node:fs/promises'; import packageDef from '~/package.json'; -import { KeysWithValueAssignableTo } from '@/shared/types'; export function checkRunning (pid: number) { diff --git a/src/bun/utils/downloader.ts b/src/bun/utils/downloader.ts index 000542a..f0f30ca 100644 --- a/src/bun/utils/downloader.ts +++ b/src/bun/utils/downloader.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import { createWriteStream } from "node:fs"; import { config, jar } from "../api/app"; import { moveAllFiles } from "../utils"; -import { DownloadFileEntry } from "@/shared/types"; +import { DownloadFileEntry } from "@simeonradivoev/gameflow-sdk/shared"; export interface ProgressStats { diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index bbb26c3..3dec17a 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; -import { SystemInfoType } from "@/shared/constants"; +import { SystemInfoType } from '@simeonradivoev/gameflow-sdk/shared'; import LoadingScreen from "./LoadingScreen"; import { GamepadKeyboard } from "./GamepadKeyboard"; diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 9d6632c..72c391a 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -5,7 +5,7 @@ import { JSX, Suspense } from 'react'; import { FloatingShortcuts } from './Shortcuts'; import { AutoFocus } from './AutoFocus'; import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts'; -import { GameListFilterType } from '@/shared/constants'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; diff --git a/src/mainview/components/FilePicker.tsx b/src/mainview/components/FilePicker.tsx index 67c1a8b..aefa842 100644 --- a/src/mainview/components/FilePicker.tsx +++ b/src/mainview/components/FilePicker.tsx @@ -4,7 +4,7 @@ import { FocusEventHandler, useContext, useRef, useState } from "react"; import path from "pathe"; import { Check, File, FileInput, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react"; import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; -import { DirType } from "@/shared/constants"; +import { DirType } from '@simeonradivoev/gameflow-sdk/shared'; import classNames from "classnames"; import { twMerge } from "tailwind-merge"; import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts"; diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index c6b8e12..093be25 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -4,7 +4,7 @@ import { FileQuestion, HardDrive, Store } from "lucide-react"; import { JSX } from "react"; import { FOCUS_KEYS } from "../scripts/types"; import { useRouter } from "@tanstack/react-router"; -import { FrontEndGameType, FrontEndId } from "@/shared/types"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams) { diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index 67e689e..80c4944 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,13 +1,14 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; -import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants"; +import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { useNavigate } from "@tanstack/react-router"; import { HardDrive } from "lucide-react"; import { JSX, useContext } from "react"; import { useLocalSetting } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import { allGamesQuery } from "@queries/romm"; -import { FrontEndGameType, FrontEndId } from "@/shared/types"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export interface GameListParams extends FocusParams { diff --git a/src/mainview/components/GamepadKeyboard.tsx b/src/mainview/components/GamepadKeyboard.tsx index 7f3b994..37e533b 100644 --- a/src/mainview/components/GamepadKeyboard.tsx +++ b/src/mainview/components/GamepadKeyboard.tsx @@ -60,7 +60,7 @@ function buildWheel (side: 0 | 1, shift: boolean, characters: boolean) const elements: JSX.Element[] = []; const refs: RefObject[] = []; const positions: { left: string; top: string; }[] = []; - const W = 258, C = 129, R2 = 107, R1 = 42, n = GetKeys(characters)[side].length, GAP = 0.028; + const n = GetKeys(characters)[side].length, GAP = 0.028; for (let i = 0; i < n; i++) { diff --git a/src/mainview/components/HeaderSearchField.tsx b/src/mainview/components/HeaderSearchField.tsx index 36d0eb0..823af58 100644 --- a/src/mainview/components/HeaderSearchField.tsx +++ b/src/mainview/components/HeaderSearchField.tsx @@ -5,7 +5,6 @@ import { oneShot } from "../scripts/audio/audio"; import { Search } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { useEventListener } from "usehooks-ts"; -import useActiveControl from "../scripts/gamepads"; import { twMerge } from "tailwind-merge"; function SearchInput (data: { diff --git a/src/mainview/components/LoadMoreButton.tsx b/src/mainview/components/LoadMoreButton.tsx index d042049..d52e4e0 100644 --- a/src/mainview/components/LoadMoreButton.tsx +++ b/src/mainview/components/LoadMoreButton.tsx @@ -1,6 +1,7 @@ import { setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FOCUS_KEYS } from "../scripts/types"; import { useIntersectionObserver } from "usehooks-ts"; +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export default function LoadMoreButton (data: { isFetching: boolean; hidden?: boolean, lastId?: FrontEndId; } & FocusParams & InteractParams) { diff --git a/src/mainview/components/Notifications.tsx b/src/mainview/components/Notifications.tsx index 4fbe03d..c13c4b6 100644 --- a/src/mainview/components/Notifications.tsx +++ b/src/mainview/components/Notifications.tsx @@ -1,5 +1,5 @@ import { RPC_URL } from "@/shared/constants"; -import { FrontendNotification } from "@/shared/types"; +import { FrontendNotification } from "@simeonradivoev/gameflow-sdk/shared"; import { Clock, CloudUpload, Save } from "lucide-react"; import { useEffect } from "react"; import toast, { ToastOptions } from "react-hot-toast"; diff --git a/src/mainview/components/SideFilters.tsx b/src/mainview/components/SideFilters.tsx index 180030d..6f99336 100644 --- a/src/mainview/components/SideFilters.tsx +++ b/src/mainview/components/SideFilters.tsx @@ -1,4 +1,4 @@ -import { GameListFilterType } from "@/shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { RoundButton } from "./RoundButton"; import classNames from "classnames"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; @@ -6,7 +6,7 @@ import { useFocusable, FocusContext } from "@noriginmedia/norigin-spatial-naviga import { ArrowDownAz, ClockArrowDown, CalendarArrowDown, Rocket, HardDrive, SortDesc, User, Drama, FunnelX, Store } from "lucide-react"; import { sourceIconMap } from "./Constants"; import { useContextDialog, ContextList, DialogEntry } from "./ContextDialog"; -import { FrontEndFilterLists } from "@/shared/types"; +import { FrontEndFilterLists } from "@simeonradivoev/gameflow-sdk/shared"; function FilterButton (data: { id: string, diff --git a/src/mainview/components/game/Achievements.tsx b/src/mainview/components/game/Achievements.tsx index e9445cb..9fbe814 100644 --- a/src/mainview/components/game/Achievements.tsx +++ b/src/mainview/components/game/Achievements.tsx @@ -1,5 +1,5 @@ -import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@/shared/types"; +import { FrontEndGameTypeDetailed, FrontEndGameTypeDetailedAchievement } from "@simeonradivoev/gameflow-sdk/shared"; import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { Medal } from "lucide-react"; diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 02db473..1a60c93 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -10,7 +10,7 @@ import ActionButton from "./ActionButton"; import { useLocalStorage } from "usehooks-ts"; import FocusTooltip from "../FocusTooltip"; import { useBlocker, useNavigate, useRouter } from "@tanstack/react-router"; -import { FrontEndGameTypeDetailed } from "@/shared/types"; +import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared"; function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams) { diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index c0ac4ea..99a7054 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -10,7 +10,7 @@ import prettyMilliseconds from 'pretty-ms'; import { useQuery } from "@tanstack/react-query"; import { validateSourceQuery } from "@/mainview/scripts/queries/romm"; import { sourceIconMap } from "../Constants"; -import { FrontEndGameTypeDetailed } from "@/shared/types"; +import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared"; export function DetailElement (data: { icon: JSX.Element; tooltip?: string | null, children?: any | any[]; }) { diff --git a/src/mainview/components/game/GameLookup.tsx b/src/mainview/components/game/GameLookup.tsx index 3b15009..bac4928 100644 --- a/src/mainview/components/game/GameLookup.tsx +++ b/src/mainview/components/game/GameLookup.tsx @@ -6,7 +6,7 @@ import HeaderSearchField from "../HeaderSearchField"; import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { scrollIntoViewHandler } from "@/mainview/scripts/utils"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; -import { FrontEndId, GameLookup } from "@/shared/types"; +import { FrontEndId, GameLookup } from "@simeonradivoev/gameflow-sdk/shared"; import { gameLookupQuery } from "@/mainview/scripts/queries/romm"; import { Button } from "../options/Button"; import { useNavigate } from "@tanstack/react-router"; diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index a2caabc..20bb27b 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -9,9 +9,8 @@ import { Clock, Crosshair, Download, EllipsisVertical, Import, PackageOpen, Play import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm"; import ActionButton from "./ActionButton"; import { useRouter } from "@tanstack/react-router"; -import { DownloadSourceType } from "@/shared/constants"; import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts"; -import { CommandEntry, FrontEndGameTypeDetailed } from "@/shared/types"; +import { CommandEntry, FrontEndGameTypeDetailed, DownloadSourceType } from "@simeonradivoev/gameflow-sdk/shared"; export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; }) { diff --git a/src/mainview/components/options/DownloadDirectoryOption.tsx b/src/mainview/components/options/DownloadDirectoryOption.tsx index 9cbe29f..de902d3 100644 --- a/src/mainview/components/options/DownloadDirectoryOption.tsx +++ b/src/mainview/components/options/DownloadDirectoryOption.tsx @@ -2,8 +2,7 @@ import { useState } from "react"; import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption"; import { useMutation, useQuery } from "@tanstack/react-query"; import { changeDownloadsMutation, getSettingQuery } from "@queries/settings"; -import { SettingsType } from "@/shared/constants"; -import { KeysWithValueAssignableTo } from "@/shared/types"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export default function DownloadDirectoryOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo; }) { diff --git a/src/mainview/components/options/LocalOption.tsx b/src/mainview/components/options/LocalOption.tsx index d596123..25ac7b6 100644 --- a/src/mainview/components/options/LocalOption.tsx +++ b/src/mainview/components/options/LocalOption.tsx @@ -1,5 +1,5 @@ import { JSX } from "react"; -import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants"; +import { LocalSettingsSchema, LocalSettingsType } from '@simeonradivoev/gameflow-sdk/shared'; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; import { useLocalStorage } from "usehooks-ts"; diff --git a/src/mainview/components/options/PathSettingsOption.tsx b/src/mainview/components/options/PathSettingsOption.tsx index 2c25fb2..7b2789f 100644 --- a/src/mainview/components/options/PathSettingsOption.tsx +++ b/src/mainview/components/options/PathSettingsOption.tsx @@ -1,5 +1,4 @@ import { HTMLInputTypeAttribute, JSX, useEffect, useState } from "react"; -import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; @@ -9,7 +8,7 @@ import { ContextDialog } from "../ContextDialog"; import FilePicker from "../FilePicker"; import { setFocus } from "@noriginmedia/norigin-spatial-navigation"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; -import { KeysWithValueAssignableTo } from "@/shared/types"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export interface PathSettingsOptionParams { diff --git a/src/mainview/components/options/SettingsDropdown.tsx b/src/mainview/components/options/SettingsDropdown.tsx index 18eabd5..6887b52 100644 --- a/src/mainview/components/options/SettingsDropdown.tsx +++ b/src/mainview/components/options/SettingsDropdown.tsx @@ -1,10 +1,9 @@ import { JSX, useCallback, useEffect, useState } from "react"; -import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; import { OptionDropdown } from "./OptionDropdown"; -import { KeysWithValueAssignableTo } from "@/shared/types"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export function SettingsDropdown (data: { label: string; diff --git a/src/mainview/components/options/SettingsOption.tsx b/src/mainview/components/options/SettingsOption.tsx index 55357d7..20bcda0 100644 --- a/src/mainview/components/options/SettingsOption.tsx +++ b/src/mainview/components/options/SettingsOption.tsx @@ -1,10 +1,9 @@ import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react"; -import { SettingsType } from "../../../shared/constants"; import { useMutation, useQuery } from "@tanstack/react-query"; import { OptionSpace } from "./OptionSpace"; import { OptionInput } from "./OptionInput"; import { getSettingQuery, setSettingMutation } from "@queries/settings"; -import { KeysWithValueAssignableTo } from "@/shared/types"; +import { KeysWithValueAssignableTo, SettingsType } from "@simeonradivoev/gameflow-sdk/shared"; export function SettingsOption (data: { label: string; diff --git a/src/mainview/components/store/EmulatorsSection.tsx b/src/mainview/components/store/EmulatorsSection.tsx index eec1325..a7712dc 100644 --- a/src/mainview/components/store/EmulatorsSection.tsx +++ b/src/mainview/components/store/EmulatorsSection.tsx @@ -12,7 +12,7 @@ import { StoreEmulatorCard } from "./StoreEmulatorCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; import { useRouter } from "@tanstack/react-router"; -import { FrontEndEmulator } from "@/shared/types"; +import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant?: boolean; }) => void; }) { diff --git a/src/mainview/components/store/GamesSection.tsx b/src/mainview/components/store/GamesSection.tsx index ff3cbbb..ccdd076 100644 --- a/src/mainview/components/store/GamesSection.tsx +++ b/src/mainview/components/store/GamesSection.tsx @@ -10,7 +10,7 @@ import FrontEndGameCard from "../FrontEndGameCard"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import Carousel from "../Carousel"; import { twMerge } from "tailwind-merge"; -import { FrontEndGameType, FrontEndId } from "@/shared/types"; +import { FrontEndGameType, FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export function GamesSection (data: { games?: FrontEndGameType[]; diff --git a/src/mainview/components/store/MissingEmulatorsSection.tsx b/src/mainview/components/store/MissingEmulatorsSection.tsx index 84f150b..6339dde 100644 --- a/src/mainview/components/store/MissingEmulatorsSection.tsx +++ b/src/mainview/components/store/MissingEmulatorsSection.tsx @@ -8,7 +8,7 @@ import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts"; import { RPC_URL } from "@/shared/constants"; import { FOCUS_KEYS } from "@/mainview/scripts/types"; import { oneShot } from "@/mainview/scripts/audio/audio"; -import { FrontEndEmulator } from "@/shared/types"; +import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; // ── Single missing-emulator card ─────────────────────────────────────────── interface MissingCardProps diff --git a/src/mainview/components/store/StoreEmulatorCard.tsx b/src/mainview/components/store/StoreEmulatorCard.tsx index 09b3ca3..8645a01 100644 --- a/src/mainview/components/store/StoreEmulatorCard.tsx +++ b/src/mainview/components/store/StoreEmulatorCard.tsx @@ -10,7 +10,7 @@ import { JSX } from "react"; import { oneShot } from "@/mainview/scripts/audio/audio"; import { useQuery } from "@tanstack/react-query"; import { getUpdateInfoForEmulator } from "@/mainview/scripts/queries/store"; -import { FrontEndEmulator } from "@/shared/types"; +import { FrontEndEmulator } from "@simeonradivoev/gameflow-sdk/shared"; export const emulatorStatusIcons: Record = { store: , diff --git a/src/mainview/gen/routeTree.gen.ts b/src/mainview/gen/routeTree.gen.ts index ad674ce..62fef18 100644 --- a/src/mainview/gen/routeTree.gen.ts +++ b/src/mainview/gen/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as SettingsAboutRouteImport } from './../routes/settings/about' import { Route as GameAddRouteImport } from './../routes/game/add' import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route' import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index' +import { Route as StoreTabPluginsRouteImport } from './../routes/store/tab/plugins' import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games' import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators' import { Route as SettingsPluginSourceRouteImport } from './../routes/settings/plugin.$source' @@ -30,6 +31,7 @@ import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$sour import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id' import { Route as EmbeddedSourceIdRouteImport } from './../routes/embedded.$source.$id' import { Route as CollectionSourceIdRouteImport } from './../routes/collection.$source.$id' +import { Route as StoreDetailsPluginIdRouteImport } from './../routes/store/details.plugin.$id' import { Route as StoreDetailsEmulatorIdRouteImport } from './../routes/store/details.emulator.$id' import { Route as GameUpdateSourceIdRouteImport } from './../routes/game/update.$source.$id' @@ -98,6 +100,11 @@ const StoreTabIndexRoute = StoreTabIndexRouteImport.update({ path: '/', getParentRoute: () => StoreTabRouteRoute, } as any) +const StoreTabPluginsRoute = StoreTabPluginsRouteImport.update({ + id: '/plugins', + path: '/plugins', + getParentRoute: () => StoreTabRouteRoute, +} as any) const StoreTabGamesRoute = StoreTabGamesRouteImport.update({ id: '/games', path: '/games', @@ -138,6 +145,11 @@ const CollectionSourceIdRoute = CollectionSourceIdRouteImport.update({ path: '/collection/$source/$id', getParentRoute: () => rootRouteImport, } as any) +const StoreDetailsPluginIdRoute = StoreDetailsPluginIdRouteImport.update({ + id: '/store/details/plugin/$id', + path: '/store/details/plugin/$id', + getParentRoute: () => rootRouteImport, +} as any) const StoreDetailsEmulatorIdRoute = StoreDetailsEmulatorIdRouteImport.update({ id: '/store/details/emulator/$id', path: '/store/details/emulator/$id', @@ -170,9 +182,11 @@ export interface FileRoutesByFullPath { '/settings/plugin/$source': typeof SettingsPluginSourceRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/': typeof StoreTabIndexRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -194,9 +208,11 @@ export interface FileRoutesByTo { '/settings/plugin/$source': typeof SettingsPluginSourceRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab': typeof StoreTabIndexRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -220,9 +236,11 @@ export interface FileRoutesById { '/settings/plugin/$source': typeof SettingsPluginSourceRoute '/store/tab/emulators': typeof StoreTabEmulatorsRoute '/store/tab/games': typeof StoreTabGamesRoute + '/store/tab/plugins': typeof StoreTabPluginsRoute '/store/tab/': typeof StoreTabIndexRoute '/game/update/$source/$id': typeof GameUpdateSourceIdRoute '/store/details/emulator/$id': typeof StoreDetailsEmulatorIdRoute + '/store/details/plugin/$id': typeof StoreDetailsPluginIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -247,9 +265,11 @@ export interface FileRouteTypes { | '/settings/plugin/$source' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab/' | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -271,9 +291,11 @@ export interface FileRouteTypes { | '/settings/plugin/$source' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab' | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' id: | '__root__' | '/' @@ -296,9 +318,11 @@ export interface FileRouteTypes { | '/settings/plugin/$source' | '/store/tab/emulators' | '/store/tab/games' + | '/store/tab/plugins' | '/store/tab/' | '/game/update/$source/$id' | '/store/details/emulator/$id' + | '/store/details/plugin/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -314,6 +338,7 @@ export interface RootRouteChildren { PlatformSourceIdRoute: typeof PlatformSourceIdRoute GameUpdateSourceIdRoute: typeof GameUpdateSourceIdRoute StoreDetailsEmulatorIdRoute: typeof StoreDetailsEmulatorIdRoute + StoreDetailsPluginIdRoute: typeof StoreDetailsPluginIdRoute } declare module '@tanstack/react-router' { @@ -409,6 +434,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StoreTabIndexRouteImport parentRoute: typeof StoreTabRouteRoute } + '/store/tab/plugins': { + id: '/store/tab/plugins' + path: '/plugins' + fullPath: '/store/tab/plugins' + preLoaderRoute: typeof StoreTabPluginsRouteImport + parentRoute: typeof StoreTabRouteRoute + } '/store/tab/games': { id: '/store/tab/games' path: '/games' @@ -465,6 +497,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CollectionSourceIdRouteImport parentRoute: typeof rootRouteImport } + '/store/details/plugin/$id': { + id: '/store/details/plugin/$id' + path: '/store/details/plugin/$id' + fullPath: '/store/details/plugin/$id' + preLoaderRoute: typeof StoreDetailsPluginIdRouteImport + parentRoute: typeof rootRouteImport + } '/store/details/emulator/$id': { id: '/store/details/emulator/$id' path: '/store/details/emulator/$id' @@ -511,12 +550,14 @@ const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren( interface StoreTabRouteRouteChildren { StoreTabEmulatorsRoute: typeof StoreTabEmulatorsRoute StoreTabGamesRoute: typeof StoreTabGamesRoute + StoreTabPluginsRoute: typeof StoreTabPluginsRoute StoreTabIndexRoute: typeof StoreTabIndexRoute } const StoreTabRouteRouteChildren: StoreTabRouteRouteChildren = { StoreTabEmulatorsRoute: StoreTabEmulatorsRoute, StoreTabGamesRoute: StoreTabGamesRoute, + StoreTabPluginsRoute: StoreTabPluginsRoute, StoreTabIndexRoute: StoreTabIndexRoute, } @@ -537,6 +578,7 @@ const rootRouteChildren: RootRouteChildren = { PlatformSourceIdRoute: PlatformSourceIdRoute, GameUpdateSourceIdRoute: GameUpdateSourceIdRoute, StoreDetailsEmulatorIdRoute: StoreDetailsEmulatorIdRoute, + StoreDetailsPluginIdRoute: StoreDetailsPluginIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/mainview/query-options.ts b/src/mainview/query-options.ts index a52c649..879d632 100644 --- a/src/mainview/query-options.ts +++ b/src/mainview/query-options.ts @@ -1,6 +1,7 @@ import { keepPreviousData, queryOptions } from "@tanstack/react-query"; import { getRomApiRomsIdGetOptions, getRomsApiRomsGetOptions } from "../clients/romm/@tanstack/react-query.gen"; -import { DefaultRommStaleTime, GameListFilterType } from "../shared/constants"; +import { DefaultRommStaleTime } from "../shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; export function gamesQueryOptions (filter?: GameListFilterType) { diff --git a/src/mainview/routes/collection.$source.$id.tsx b/src/mainview/routes/collection.$source.$id.tsx index 3b73d25..a08c164 100644 --- a/src/mainview/routes/collection.$source.$id.tsx +++ b/src/mainview/routes/collection.$source.$id.tsx @@ -6,7 +6,7 @@ import { AnimatedBackgroundContext } from '../scripts/contexts'; import { getCollectionQuery } from '@queries/romm'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; -import { GameListFilterType } from '@/shared/constants'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { useLocalStorage } from 'usehooks-ts'; export const Route = createFileRoute('/collection/$source/$id')({ diff --git a/src/mainview/routes/game/$source.$id.tsx b/src/mainview/routes/game/$source.$id.tsx index 064deee..761e9ea 100644 --- a/src/mainview/routes/game/$source.$id.tsx +++ b/src/mainview/routes/game/$source.$id.tsx @@ -24,7 +24,7 @@ import Details from "@/mainview/components/game/Details"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import SelectMenu from "@/mainview/components/SelectMenu"; import { IGDBIcon } from "@/mainview/scripts/brandIcons"; -import { FrontEndGameTypeDetailed } from "@/shared/types"; +import { FrontEndGameTypeDetailed } from "@simeonradivoev/gameflow-sdk/shared"; export const Route = createFileRoute("/game/$source/$id")({ loader: async ({ params, context }) => diff --git a/src/mainview/routes/game/update.$source.$id.tsx b/src/mainview/routes/game/update.$source.$id.tsx index 867112a..0a6ef83 100644 --- a/src/mainview/routes/game/update.$source.$id.tsx +++ b/src/mainview/routes/game/update.$source.$id.tsx @@ -1,15 +1,15 @@ import { AnimatedBackground } from '@/mainview/components/AnimatedBackground'; import { AutoFocus } from '@/mainview/components/AutoFocus'; import GameLookupElement from '@/mainview/components/game/GameLookup'; -import { HeaderUI, StickyHeaderUI } from '@/mainview/components/Header'; +import { HeaderUI } from '@/mainview/components/Header'; import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { customUpdateMutation, gameInvalidationQuery, gameQuery } from '@/mainview/scripts/queries/romm'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import { HandleGoBack } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; -import { useEffect, useRef, useState } from 'react'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; +import { useEffect, useState } from 'react'; import toast from 'react-hot-toast'; export const Route = createFileRoute('/game/update/$source/$id')({ diff --git a/src/mainview/routes/games.tsx b/src/mainview/routes/games.tsx index e6fd86e..cd1fe45 100644 --- a/src/mainview/routes/games.tsx +++ b/src/mainview/routes/games.tsx @@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { CollectionsDetail } from '../components/CollectionsDetail'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; -import { GameListFilterType } from '@/shared/constants'; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { useSessionStorage } from 'usehooks-ts'; import HeaderSearchField from '../components/HeaderSearchField'; import { useEffect } from 'react'; diff --git a/src/mainview/routes/index.tsx b/src/mainview/routes/index.tsx index 9505d30..a9d33c1 100644 --- a/src/mainview/routes/index.tsx +++ b/src/mainview/routes/index.tsx @@ -50,7 +50,7 @@ import SelectMenu from "../components/SelectMenu"; import HeaderSearchField from "../components/HeaderSearchField"; import CardElement from "../components/CardElement"; import { Router } from ".."; -import { FrontEndId } from "@/shared/types"; +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, diff --git a/src/mainview/routes/platform.$source.$id.tsx b/src/mainview/routes/platform.$source.$id.tsx index 73d0265..f4df81d 100644 --- a/src/mainview/routes/platform.$source.$id.tsx +++ b/src/mainview/routes/platform.$source.$id.tsx @@ -1,7 +1,8 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"; import { CollectionsDetail } from "../components/CollectionsDetail"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { GameListFilterType, RPC_URL } from "../../shared/constants"; +import { RPC_URL } from "../../shared/constants"; +import { GameListFilterType } from '@simeonradivoev/gameflow-sdk/shared'; import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm"; import { zodValidator } from "@tanstack/zod-adapter"; import z from "zod"; diff --git a/src/mainview/routes/settings/accounts.tsx b/src/mainview/routes/settings/accounts.tsx index ce5586c..eacd432 100644 --- a/src/mainview/routes/settings/accounts.tsx +++ b/src/mainview/routes/settings/accounts.tsx @@ -13,7 +13,8 @@ import useEffect, useRef, } from "react"; -import { RommLoginDataSchema, RPC_URL } from "@shared/constants"; +import { RPC_URL } from "@shared/constants"; +import { RommLoginDataSchema } from '@simeonradivoev/gameflow-sdk/shared'; import toast from "react-hot-toast"; import { OptionSpace } from "../../components/options/OptionSpace"; import { useSettingsForm, useSettingsFormContext } from "../../components/options/SettingsAppForm"; diff --git a/src/mainview/routes/settings/directories.tsx b/src/mainview/routes/settings/directories.tsx index 4af256b..5adee26 100644 --- a/src/mainview/routes/settings/directories.tsx +++ b/src/mainview/routes/settings/directories.tsx @@ -13,10 +13,13 @@ import { systemApi } from '@/mainview/scripts/clientApi'; import useActiveControl from '@/mainview/scripts/gamepads'; import { changeDownloadsMutation } from '@queries/settings'; import { downloadDrivesQuery } from '@/mainview/scripts/queries/system'; -import { DownloadsDrive } from '@/shared/types'; +import { DownloadsDrive } from '@simeonradivoev/gameflow-sdk/shared'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/settings/directories')({ component: RouteComponent, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; }) diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index 7e3f381..9abddb4 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -8,7 +8,8 @@ import { Check, ChevronDown, FolderSearch, HardDrive, Plug, SearchAlert, Store, import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; -import { RPC_URL, SettingsSchema } from '../../../shared/constants'; +import { RPC_URL } from '../../../shared/constants'; +import { SettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import emulators from '@emulators'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; @@ -20,11 +21,14 @@ import { FOCUS_KEYS } from '@/mainview/scripts/types'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { SettingsOption } from '@/mainview/components/options/SettingsOption'; import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown'; -import { FrontEndEmulator } from '@/shared/types'; +import { FrontEndEmulator } from '@simeonradivoev/gameflow-sdk/shared'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, pendingComponent: EmulatorsPending, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); function EmulatorsPending () diff --git a/src/mainview/routes/settings/interface.tsx b/src/mainview/routes/settings/interface.tsx index f1a42c8..8ef70d4 100644 --- a/src/mainview/routes/settings/interface.tsx +++ b/src/mainview/routes/settings/interface.tsx @@ -1,11 +1,15 @@ import { LocalOption } from '@/mainview/components/options/LocalOption'; -import { LocalSettingsSchema, settingRegistry } from '@/shared/constants'; +import { settingRegistry } from '@simeonradivoev/gameflow-sdk/shared'; +import { LocalSettingsSchema } from '@simeonradivoev/gameflow-sdk/shared'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { createFileRoute } from '@tanstack/react-router'; import { Terminal } from 'lucide-react'; +import { zodValidator } from '@tanstack/zod-adapter'; +import z from 'zod'; export const Route = createFileRoute('/settings/interface')({ component: RouteComponent, + validateSearch: zodValidator(z.object({ focus: z.string().optional() })) }); function RouteComponent () diff --git a/src/mainview/routes/settings/plugin.$source.tsx b/src/mainview/routes/settings/plugin.$source.tsx index 7263bea..c80b592 100644 --- a/src/mainview/routes/settings/plugin.$source.tsx +++ b/src/mainview/routes/settings/plugin.$source.tsx @@ -5,23 +5,25 @@ import { OptionDropdown } from '@/mainview/components/options/OptionDropdown'; import { OptionInput } from '@/mainview/components/options/OptionInput'; import { OptionSpace } from '@/mainview/components/options/OptionSpace'; import { RoundButton } from '@/mainview/components/RoundButton'; -import { getPluginDetailsQuery } from '@/mainview/scripts/queries/plugins'; +import { allPluginsFilter, getPluginDetailsQuery, updatePluginMutation } from '@/mainview/scripts/queries/plugins'; import { getPluginActionsQuery, getPluginSettingQuery, getPluginSettingsDefinitionQuery, pluginActionMutation, setPluginSettingMutation } from '@/mainview/scripts/queries/settings'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import { scrollIntoViewHandler } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { PluginUpdateCheck } from '@simeonradivoev/gameflow-sdk/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { JSONSchema7 } from 'json-schema'; -import { ArrowLeft, CirclePlay, Settings2 } from 'lucide-react'; +import { ArrowLeft, ArrowRight, CircleFadingArrowUp, CirclePlay, Settings2 } from 'lucide-react'; import toast from 'react-hot-toast'; export const Route = createFileRoute('/settings/plugin/$source')({ component: RouteComponent, pendingComponent: Loading, async loader (ctx) { - const definitions = await ctx.context.queryClient.fetchQuery(getPluginSettingsDefinitionQuery(ctx.params.source)); - const actions = await ctx.context.queryClient.fetchQuery(getPluginActionsQuery(ctx.params.source)); + const source = decodeURIComponent(ctx.params.source); + const definitions = await ctx.context.queryClient.fetchQuery(getPluginSettingsDefinitionQuery(source)); + const actions = await ctx.context.queryClient.fetchQuery(getPluginActionsQuery(source)); await new Promise(resolve => setTimeout(resolve, 1000)); return { definitions, actions }; }, @@ -38,7 +40,8 @@ function Loading () function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; }) { - const { source } = Route.useParams(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); const action = useMutation({ ...pluginActionMutation(source, data.id), onSuccess (acitonData, variables, onMutateResult, context) @@ -67,7 +70,8 @@ function PluginAction (data: { id: string, title: string | undefined, descriptio function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; }) { - const { source } = Route.useParams(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); const { data: value, refetch: refetchValue } = useQuery(getPluginSettingQuery(source, data.name)); const setValue = useMutation({ ...setPluginSettingMutation(source, data.name), @@ -108,12 +112,21 @@ function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; ; } -function Settings () +function Settings (data: { update: PluginUpdateCheck | undefined; }) { const { definitions, actions } = Route.useLoaderData(); - const { source } = Route.useParams(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); const queryClient = useQueryClient(); - + const navigate = useNavigate(); + const update = useMutation({ + ...updatePluginMutation(source), + onSuccess (data, variables, onMutateResult, context) + { + context.client.invalidateQueries(allPluginsFilter); + navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(source) }, replace: true }); + }, + }); const handleReload = () => { queryClient.refetchQueries(getPluginSettingsDefinitionQuery(source)); @@ -121,7 +134,7 @@ function Settings () }; const { ref, focusKey } = useFocusable({ focusKey: 'plugin-settings', - focusable: (definitions?.properties && Object.keys(definitions?.properties).length > 0) || actions.length > 0 + focusable: (definitions?.properties && Object.keys(definitions?.properties).length > 0) || actions.length > 0 || !!data.update }); return
@@ -148,6 +161,15 @@ function Settings () })}
Actions
+ {!!data.update && +
Update
+
{data?.update?.current} {'>'} {data?.update?.new}
+
}> + + } {actions?.map(a => )} ; @@ -155,7 +177,8 @@ function Settings () function RouteComponent () { - const { source } = Route.useParams(); + const { source: sourceRaw } = Route.useParams(); + const source = decodeURIComponent(sourceRaw); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' }); const { data } = useQuery(getPluginDetailsQuery(source)); @@ -167,17 +190,17 @@ function RouteComponent ()
-
+
- {data?.displayName} +
{data?.displayName}
+
{data?.version}
+ {!!data?.update &&
{data?.update.new}
}
    {data?.keywords?.map((k, i) =>
  • {k}
  • )}
{data?.description}
- - - +
; diff --git a/src/mainview/routes/settings/plugins.tsx b/src/mainview/routes/settings/plugins.tsx index c9476ba..c55fc8f 100644 --- a/src/mainview/routes/settings/plugins.tsx +++ b/src/mainview/routes/settings/plugins.tsx @@ -3,13 +3,13 @@ import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/compon import { OptionInput } from '@/mainview/components/options/OptionInput'; import { OptionSpace } from '@/mainview/components/options/OptionSpace'; import { RoundButton } from '@/mainview/components/RoundButton'; -import { enablePluginMutation, getAllPluginsQuery } from '@/mainview/scripts/queries/plugins'; +import { enablePluginMutation, getAllPluginsQuery, uninstallPluginMutation } from '@/mainview/scripts/queries/plugins'; import { GamePadButtonCode, Shortcut } from '@/mainview/scripts/shortcuts'; -import { FrontendPlugin } from '@/shared/types'; +import { FrontendPlugin } from '@simeonradivoev/gameflow-sdk/shared'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useMutation, useQuery } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; -import { Eye, Puzzle, Search, Settings2 } from 'lucide-react'; +import { CircleFadingArrowUp, Eye, Puzzle, Settings2, Trash } from 'lucide-react'; export const Route = createFileRoute('/settings/plugins')({ component: RouteComponent, @@ -33,7 +33,9 @@ function Plugin (data: { }, }); - const handleDetails = () => navigate({ to: '/settings/plugin/$source', params: { source: data.plugin.name }, replace: true, viewTransition: { types: ['slide-up'] } }); + const uninstall = useMutation(uninstallPluginMutation(data.plugin.name)); + const handleUninstall = () => uninstall.mutate(); + const handleDetails = () => navigate({ to: '/settings/plugin/$source', params: { source: encodeURIComponent(data.plugin.name) }, replace: true, viewTransition: { types: ['slide-up'] } }); return : }
-
{data.plugin.displayName}
+
{data.plugin.displayName ?? data.plugin.name}
{data.plugin.name} ({data.plugin.version})
{data.plugin.hasSettings && } + {data.plugin.update &&
+ +
}
@@ -55,6 +60,7 @@ function Plugin (data: { >
{data.plugin.hasSettings ? : } + {data.plugin.canUninstall && {uninstall.isPending ? : }} {data.plugin.canDisable && data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />}
; diff --git a/src/mainview/routes/settings/update.tsx b/src/mainview/routes/settings/update.tsx index 905ada6..3f27639 100644 --- a/src/mainview/routes/settings/update.tsx +++ b/src/mainview/routes/settings/update.tsx @@ -3,7 +3,7 @@ import DotsLoading from '@/mainview/components/backgrounds/dots'; import { Button } from '@/mainview/components/options/Button'; import { checkUpdateMutation, hasUpdateQuery, updateMutation } from '@/mainview/scripts/queries/system'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { CircleFadingArrowUp, RefreshCcw } from 'lucide-react'; import { MarkdownAsync } from 'react-markdown'; diff --git a/src/mainview/routes/store/details.emulator.$id.tsx b/src/mainview/routes/store/details.emulator.$id.tsx index 60b3917..3383ea8 100644 --- a/src/mainview/routes/store/details.emulator.$id.tsx +++ b/src/mainview/routes/store/details.emulator.$id.tsx @@ -29,7 +29,7 @@ import FocusTooltip from "@/mainview/components/FocusTooltip"; import { AutoFocus } from "@/mainview/components/AutoFocus"; import { FilterUI } from "@/mainview/components/Filters"; import Markdown from "react-markdown"; -import { FrontEndEmulatorDetailed } from "@/shared/types"; +import { FrontEndEmulatorDetailed } from "@simeonradivoev/gameflow-sdk/shared"; export const Route = createFileRoute('/store/details/emulator/$id')({ component: RouteComponent, diff --git a/src/mainview/routes/store/details.plugin.$id.tsx b/src/mainview/routes/store/details.plugin.$id.tsx new file mode 100644 index 0000000..5e5168c --- /dev/null +++ b/src/mainview/routes/store/details.plugin.$id.tsx @@ -0,0 +1,161 @@ +import { AutoFocus } from '@/mainview/components/AutoFocus'; +import DotsLoading from '@/mainview/components/backgrounds/dots'; +import { StickyHeaderUI } from '@/mainview/components/Header'; +import LoadingScreen from '@/mainview/components/LoadingScreen'; +import { Button } from '@/mainview/components/options/Button'; +import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; +import StatList, { StatEntry } from '@/mainview/components/StatList'; +import { installPluginMutation, pluginFilter, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins'; +import { pluginDetailsQuery } from '@/mainview/scripts/queries/store'; +import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { HandleGoBack } from '@/mainview/scripts/utils'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { QueryClient, useMutation } from '@tanstack/react-query'; +import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { ArrowRight, CircleFadingArrowUp, Download, Settings, Trash } from 'lucide-react'; +import prettyBytes from 'pretty-bytes'; +import { Suspense } from 'react'; + +export const Route = createFileRoute('/store/details/plugin/$id')({ + component: RouteComponent, + pendingComponent: Loading, + async loader (ctx) + { + const id = decodeURIComponent(ctx.params.id); + const data = await ctx.context.queryClient.fetchQuery(pluginDetailsQuery(id)); + return { data }; + }, +}); + +function Loading () +{ + const { ref, focusSelf } = useFocusable({ focusKey: 'plugin-details' }); + return <> + + + ; +} + +function Details () +{ + const { id } = Route.useParams(); + const plugin = decodeURIComponent(id); + const { data } = Route.useLoaderData(); + const navigate = useNavigate(); + const handleRefresh = (client: QueryClient) => + { + client.invalidateQueries(pluginFilter(plugin)); + navigate({ to: '/store/details/plugin/$id', params: { id: encodeURIComponent(id) }, replace: true }); + }; + const update = useMutation({ + ...updatePluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const install = useMutation({ + ...installPluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const uninstall = useMutation({ + ...uninstallPluginMutation(plugin), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + + const stats: StatEntry[] = []; + if (data.devDependencies) + { + stats.push({ content: Object.keys(data.devDependencies), label: "Dev Dependecies" }); + } + if (data.dependencies) + { + stats.push({ content: Object.keys(data.dependencies), label: "Dependecies" }); + } + if (data.maintainers) + { + stats.push({ content: data.maintainers.map(m => m.name), label: "Maintainers" }); + } + if (data.dist) + { + stats.push({ content: prettyBytes(data.dist.unpackedSize), label: "Size" }); + } + if (data.license) + { + stats.push({ content: data.license, label: "License" }); + } + return <> + +
+
+
{data.name}
+
+
+ {data.update ? <> +
{data.update.from}
+ +
{data.version}
+ : +
{data.version}
} + +
+ by {data.author?.name ?? data._npmUser?.name}
+
+
+ {data.installed && <> + {!!data.update && } + + + + } + {!data.installed && } + +
+
+
Details
+
+
{data.description}
+ +
+
Keywords
+
+ {data.keywords.map(k =>
  • {k}
  • )} +
    + ; +} + +function RouteComponent () +{ + const router = useRouter(); + const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugin-details' }); + useShortcuts(focusKey, () => [{ + label: "Return", button: GamePadButtonCode.B, action (e) + { + HandleGoBack(router, e); + }, + }]); + return
    + + + }> +
    + + + + +
    ; +} diff --git a/src/mainview/routes/store/tab/games.tsx b/src/mainview/routes/store/tab/games.tsx index c36b824..21e059f 100644 --- a/src/mainview/routes/store/tab/games.tsx +++ b/src/mainview/routes/store/tab/games.tsx @@ -8,13 +8,13 @@ import LoadMoreButton from '@/mainview/components/LoadMoreButton'; import { storeGamesInfiniteQuery } from '@queries/store'; import InvalidStoreError from '@/mainview/components/store/InvalidStoreError'; import { CardList, GameMetaExtra } from '@/mainview/components/CardList'; -import { GameListFilterType, RPC_URL } from '@/shared/constants'; +import { RPC_URL } from '@/shared/constants'; +import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared'; import { useSessionStorage } from 'usehooks-ts'; import { zodValidator } from '@tanstack/zod-adapter'; import z from 'zod'; import SideFilters from '@/mainview/components/SideFilters'; import { gameFiltersQuery } from '@/mainview/scripts/queries/romm'; -import { FrontEndGameType } from '@/shared/types'; export const Route = createFileRoute('/store/tab/games')({ component: RouteComponent, diff --git a/src/mainview/routes/store/tab/index.tsx b/src/mainview/routes/store/tab/index.tsx index 08ca653..dcb53c8 100644 --- a/src/mainview/routes/store/tab/index.tsx +++ b/src/mainview/routes/store/tab/index.tsx @@ -16,7 +16,7 @@ import { useQuery } from '@tanstack/react-query'; import { autoEmulatorsQuery } from '@queries/settings'; import { storeEmulatorsRecommendedQuery, storeFeaturedGamesQuery } from '@queries/store'; import ImageWithFallbacks from '@/mainview/components/ImageWithFallbacks'; -import { FrontEndGameTypeDetailed } from '@/shared/types'; +import { FrontEndGameTypeDetailed } from '@simeonradivoev/gameflow-sdk/shared'; export const Route = createFileRoute('/store/tab/')({ component: RouteComponent diff --git a/src/mainview/routes/store/tab/plugins.tsx b/src/mainview/routes/store/tab/plugins.tsx new file mode 100644 index 0000000..db55628 --- /dev/null +++ b/src/mainview/routes/store/tab/plugins.tsx @@ -0,0 +1,151 @@ +import { allPluginsFilter, installPluginMutation, uninstallPluginMutation, updatePluginMutation } from '@/mainview/scripts/queries/plugins'; +import { pluginsQuery } from '@/mainview/scripts/queries/store'; +import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; +import { FOCUS_KEYS } from '@/mainview/scripts/types'; +import { PluginEntryType } from '@simeonradivoev/gameflow-sdk/shared'; +import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; +import { QueryClient, useMutation, useQuery } from '@tanstack/react-query'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; +import { CircleFadingArrowUp, Dot, Download, HardDrive, Puzzle } from 'lucide-react'; +import prettyMilliseconds from 'pretty-ms'; +import { useSessionStorage } from 'usehooks-ts'; +import z from 'zod'; + +export const Route = createFileRoute('/store/tab/plugins')({ + component: RouteComponent, + validateSearch: zodValidator(z.object({ + search: z.string().optional() + })) +}); + +function PluginCard (data: { plugin: PluginEntryType; }) +{ + const navigate = useNavigate(); + const onAction = () => + { + navigate({ to: '/store/details/plugin/$id', params: { id: decodeURIComponent(data.plugin.package.name) } }); + }; + const { ref, focusKey } = useFocusable({ focusKey: FOCUS_KEYS.PLUGIN_ENTRY(data.plugin.package.sanitized_name), onEnterPress: onAction }); + const handleRefresh = (client: QueryClient) => + { + client.invalidateQueries(allPluginsFilter); + navigate({ to: '/store/tab/plugins', replace: true }); + }; + const update = useMutation({ + ...updatePluginMutation(data.plugin.package.name), + onSuccess (data, variables, onMutateResult, context) + { + handleRefresh(context.client); + }, + }); + const install = useMutation({ + ...installPluginMutation(data.plugin.package.name), + onSuccess (f, variables, onMutateResult, context) + { + handleRefresh(context.client); + } + }); + const uninstall = useMutation({ + ...uninstallPluginMutation(data.plugin.package.name), + onSuccess (f, variables, onMutateResult, context) + { + handleRefresh(context.client); + } + }); + useShortcuts(focusKey, () => + { + const shortcuts: Shortcut[] = [{ + label: "Details", button: GamePadButtonCode.A, action (e) + { + onAction(); + }, + }]; + + if (data.plugin.installed) + { + shortcuts.push({ + label: "Uninstall", + button: GamePadButtonCode.X, + action (e) + { + uninstall.mutate(); + }, + }); + + if (data.plugin.update) + { + shortcuts.push({ + label: "Update", + button: GamePadButtonCode.Y, + action (e) + { + update.mutate(); + }, + }); + } + + } else + { + shortcuts.push({ + label: "Install", + button: GamePadButtonCode.X, + action (e) + { + install.mutate(); + }, + }); + } + return shortcuts; + }, [data.plugin.installed, install.isPending, uninstall.isPending]); + return
    +
    +
    + {data.plugin.installed && } + {data.plugin.update && } + {data.plugin.package.name} + {(install.isPending || uninstall.isPending) && } +
    +
    {data.plugin.package.description}
    +
      {data.plugin.package.keywords.concat(...data.plugin.installed ? ["installed"] : []).map(k =>
    • {k}
    • )}
    +
      +
    • {data.plugin.package.publisher.username}
    • + +
    • {data.plugin.package.version}
    • + +
    • {prettyMilliseconds(new Date().getTime() - data.plugin.package.date.getTime(), { hideSeconds: true })}
    • + +
    • {data.plugin.package.license}
    • + {install.isPending && <> + +
    • installing
    • + } + {uninstall.isPending && <> + +
    • uninstalling
    • + } +
    +
    +
    +
    + + {data.plugin.downloads.monthly} +
    +
    +
    ; +} + +function RouteComponent () +{ + const [search] = useSessionStorage(`${Route.to}-search`, undefined); + const { data: plugins } = useQuery(pluginsQuery(search)); + const { ref, focusKey } = useFocusable({ focusKey: "plugins-store" }); + return
    + +
    {plugins?.total} Plugins
    +
    + {plugins?.objects.map((p, i) => )} +
    +
    +
    ; +} diff --git a/src/mainview/routes/store/tab/route.tsx b/src/mainview/routes/store/tab/route.tsx index b874445..05b4c1e 100644 --- a/src/mainview/routes/store/tab/route.tsx +++ b/src/mainview/routes/store/tab/route.tsx @@ -14,6 +14,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useMatchRoute, useRouter } from '@tanstack/react-router'; import { createFileRoute, Outlet } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; +import { Gamepad2, Home, Joystick, Puzzle } from 'lucide-react'; import { useRef } from 'react'; import { useSessionStorage } from 'usehooks-ts'; import z from 'zod'; @@ -93,9 +94,10 @@ function RouteComponent () const headerRef = useRef(null); const sentinelRef = useRef(null); const filters: Record = { - home: { label: "Home", selected: useIsSettings(''), }, - emulators: { label: "Emulators", selected: useIsSettings('emulators') }, - games: { label: "Games", selected: useIsSettings('games') } + home: { label: "Home", icon: , selected: useIsSettings(''), }, + emulators: { label: "Emulators", icon: , selected: useIsSettings('emulators') }, + games: { label: "Games", icon: , selected: useIsSettings('games') }, + plugins: { label: "Plugins", icon: , selected: useIsSettings('plugins') } }; const [search, setSearch] = useSessionStorage(`${router.history.location.pathname}-search`, undefined); const [, setGamesSearch] = useSessionStorage(`/store/tab/games-search`, undefined); diff --git a/src/mainview/scripts/contexts.ts b/src/mainview/scripts/contexts.ts index f54f0e2..3b33a01 100644 --- a/src/mainview/scripts/contexts.ts +++ b/src/mainview/scripts/contexts.ts @@ -1,8 +1,7 @@ -import { SystemInfoType } from "@/shared/constants"; +import { SystemInfoType, Drive } from '@simeonradivoev/gameflow-sdk/shared'; import { Direction, FocusDetails } from "@noriginmedia/norigin-spatial-navigation"; import { createContext } from "react"; import { Shortcut } from "./shortcuts"; -import { Drive } from "@/shared/types"; export const StoreContext = createContext({} as { showDetails: (type: 'emulator' | 'game', source: string, id: string, focusSource: string) => void; diff --git a/src/mainview/scripts/queries/plugins.ts b/src/mainview/scripts/queries/plugins.ts index 323b168..8e6e948 100644 --- a/src/mainview/scripts/queries/plugins.ts +++ b/src/mainview/scripts/queries/plugins.ts @@ -1,4 +1,4 @@ -import { mutationOptions, queryOptions } from "@tanstack/react-query"; +import { mutationOptions, QueryFilters, queryOptions } from "@tanstack/react-query"; import { pluginsApi } from "../clientApi"; export const getAllPluginsQuery = queryOptions({ @@ -14,7 +14,7 @@ export const getAllPluginsQuery = queryOptions({ export const getPluginDetailsQuery = (source: string) => queryOptions({ queryKey: ['plugins', source], queryFn: async () => { - const { data, error } = await pluginsApi.plugins({ id: source }).get(); + const { data, error } = await pluginsApi.plugins({ id: encodeURIComponent(source) }).get(); if (error) throw error; return data; } @@ -24,7 +24,51 @@ export const enablePluginMutation = mutationOptions({ mutationKey: ['plugin', 'enable'], mutationFn: async (vars: { id: string, enabled: boolean; }) => { - const { error } = await pluginsApi.plugins({ id: vars.id }).post({ enabled: vars.enabled }); + const { error } = await pluginsApi.plugins({ id: encodeURIComponent(vars.id) }).post({ enabled: vars.enabled }); if (error) throw error; } +}); + +export const installPluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'install', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.install.post({ id }); + if (error) throw error; + return data; + } +}); + +export const updatePluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'update', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.update.post({ id }); + if (error) throw error; + return data; + } +}); + +export const uninstallPluginMutation = (id: string) => mutationOptions({ + mutationKey: ['plugin', 'uninstall', id], + mutationFn: async () => + { + const { data, error } = await pluginsApi.plugins.uninstall.post({ id: id }); + if (error) throw error; + return data; + } +}); + +export const pluginFilter = (id: string): QueryFilters => ({ + predicate (query) + { + return query.queryKey.includes(id); + }, +}); + +export const allPluginsFilter: QueryFilters = ({ + predicate (query) + { + return query.queryKey.includes('plugin') || query.queryKey.includes('plugins'); + }, }); \ No newline at end of file diff --git a/src/mainview/scripts/queries/romm.ts b/src/mainview/scripts/queries/romm.ts index 4a7a4f6..63f8623 100644 --- a/src/mainview/scripts/queries/romm.ts +++ b/src/mainview/scripts/queries/romm.ts @@ -1,9 +1,9 @@ -import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants"; +import { DefaultRommStaleTime } from "@/shared/constants"; +import { GameListFilterType, RommLoginDataSchema, FrontEndId } from '@simeonradivoev/gameflow-sdk/shared'; import { rommApi, settingsApi } from "../clientApi"; import { InvalidateQueryFilters, mutationOptions, QueryClient, QueryFilters, queryOptions } from "@tanstack/react-query"; import z from "zod"; import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen"; -import { FrontEndId } from "@/shared/types"; export const allGamesQuery = (filter?: GameListFilterType) => queryOptions({ queryKey: ['games', filter ?? 'all'], diff --git a/src/mainview/scripts/queries/settings.ts b/src/mainview/scripts/queries/settings.ts index e0f605e..03956af 100644 --- a/src/mainview/scripts/queries/settings.ts +++ b/src/mainview/scripts/queries/settings.ts @@ -138,7 +138,7 @@ export const getPluginSettingsDefinitionQuery = (source: string) => queryOptions queryKey: ['settings', source, 'definitions'], queryFn: async () => { - const { data: value, error } = await settingsApi.api.settings.definitions({ source }).get(); + const { data: value, error } = await settingsApi.api.settings.definitions({ source: encodeURIComponent(source) }).get(); if (error) throw error; return value; @@ -148,7 +148,7 @@ export const getPluginSettingQuery = (source: string, id: string) => queryOption queryKey: ["setting", source, id], queryFn: async () => { - const { data, error } = await settingsApi.api.settings({ source })({ id }).get(); + const { data, error } = await settingsApi.api.settings({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).get(); if (error) throw error; return data; @@ -158,7 +158,7 @@ export const setPluginSettingMutation = (source: string, id: string) => mutation mutationKey: ["setting", source, id], mutationFn: async (value: any) => { - const { data, error } = await settingsApi.api.settings({ source })({ id }).put({ value }); + const { data, error } = await settingsApi.api.settings({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).put({ value }); if (error) throw error; return data; @@ -167,7 +167,7 @@ export const setPluginSettingMutation = (source: string, id: string) => mutation export const getPluginActionsQuery = (source: string) => queryOptions({ queryKey: ['plugin', source, 'actions'], queryFn: async () => { - const { data, error } = await settingsApi.api.settings.actions({ source }).get(); + const { data, error } = await settingsApi.api.settings.actions({ source: encodeURIComponent(source) }).get(); if (error) throw error; return data; @@ -177,7 +177,7 @@ export const pluginActionMutation = (source: string, id: string) => mutationOpti mutationKey: ["plugin", source, "action"], mutationFn: async () => { - const { data, error, response } = await settingsApi.api.settings.actions({ source })({ id }).post(); + const { data, error, response } = await settingsApi.api.settings.actions({ source: encodeURIComponent(source) })({ id: encodeURIComponent(id) }).post(); if (error) throw error; return { data: data as any, response }; diff --git a/src/mainview/scripts/queries/store.ts b/src/mainview/scripts/queries/store.ts index 6347944..a428f40 100644 --- a/src/mainview/scripts/queries/store.ts +++ b/src/mainview/scripts/queries/store.ts @@ -1,8 +1,6 @@ import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/react-query"; import { rommApi, storeApi } from "../clientApi"; -import { GameListFilterType } from "@/shared/constants"; -import { FrontEndGameType } from "@/shared/types"; - +import { GameListFilterType, FrontEndGameType } from '@simeonradivoev/gameflow-sdk/shared'; export const storeEmulatorsQuery = (filters: { search?: string; }) => queryOptions({ queryKey: ['store-emulators', filters], queryFn: async () => @@ -97,4 +95,22 @@ export const getUpdateInfoForEmulator = (id: string) => queryOptions({ if (error) throw error; return data; } +}); +export const pluginsQuery = (search?: string) => queryOptions({ + queryKey: ['plugins', 'store', search ?? 'all'], + queryFn: async () => + { + const { data, error } = await storeApi.api.store.plugins.get({ query: { search } }); + if (error) throw error; + return data; + } +}); +export const pluginDetailsQuery = (id: string) => queryOptions({ + queryKey: ['plugin', 'store', id], + queryFn: async () => + { + const { data, error } = await storeApi.api.store.plugin.get({ query: { plugin: id } }); + if (error) throw error; + return data; + } }); \ No newline at end of file diff --git a/src/mainview/scripts/types.ts b/src/mainview/scripts/types.ts index a192266..6ab944a 100644 --- a/src/mainview/scripts/types.ts +++ b/src/mainview/scripts/types.ts @@ -1,4 +1,4 @@ -import { FrontEndId } from "@/shared/types"; +import { FrontEndId } from "@simeonradivoev/gameflow-sdk/shared"; export const FOCUS_KEYS = { NAV_CATEGORIES: "NAV_CATEGORIES", @@ -14,4 +14,5 @@ export const FOCUS_KEYS = { GAME_CARD: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, GAME_MATCH: (id: FrontEndId) => `GAME_${id.source}_${id.id}`, STATS_SECTION: "STATS_SECTION", + PLUGIN_ENTRY: (id: string) => `PLUGIN_${id}` } as const; \ No newline at end of file diff --git a/src/mainview/scripts/utils.ts b/src/mainview/scripts/utils.ts index 4859ddf..6635bd6 100644 --- a/src/mainview/scripts/utils.ts +++ b/src/mainview/scripts/utils.ts @@ -1,4 +1,4 @@ -import { LocalSettingsSchema, LocalSettingsType } from "@/shared/constants"; +import { LocalSettingsSchema, LocalSettingsType } from '@simeonradivoev/gameflow-sdk/shared'; import { DependencyList, RefObject, useEffect, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { jobsApi, systemApi } from "./clientApi"; diff --git a/scripts/sdk/README.md b/src/packages/gameflow-sdk/README.md similarity index 53% rename from scripts/sdk/README.md rename to src/packages/gameflow-sdk/README.md index 7ac9193..8338233 100644 --- a/scripts/sdk/README.md +++ b/src/packages/gameflow-sdk/README.md @@ -13,3 +13,18 @@ The package must expose a main script gameflow will import and validate. It must For the plugin to show up in the UI for download. It must be published to NPM with the `gameflow-plugin` keyword. Gameflow uses bun to install plugins as packages from npmjs. Follow publishing instruction check the [NPM Docs](https://docs.npmjs.com/packages-and-modules/contributing-packages-to-the-registry) + +## Dependencies + +Peer dependencies will not be installed when the run adds the plugin package. They are provided by gameflow. +All peer dependencies can be marked as external as gameflow provides it. There is a helper build script that does all that for you, to run it use. + +`bunx gameflow-build --entry=index.ts` + +supported arguments are +`--entry` the entry of the app to build +`--outdir` Where to build. Default is 'dist' +`--minify` Minify the code. Default is 'false' +`--sourcemap` Include a source map. Default is 'none' + +If you want to include dependencies that gameflow does not provide you have to bundle them in. Gameflow does not load dependencies for you. diff --git a/src/packages/gameflow-sdk/build.ts b/src/packages/gameflow-sdk/build.ts new file mode 100644 index 0000000..9f18b88 --- /dev/null +++ b/src/packages/gameflow-sdk/build.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun + +import pkg from './package.json'; + +import { parseArgs } from "util"; + +const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + outdir: { type: "string", default: "dist" }, + minify: { type: "boolean", default: false }, + sourcemap: { type: "string", default: "none" }, // "none" | "inline" | "external" + entry: { type: "string", default: "src/index.ts" }, + }, + allowPositionals: true, +}); + +await Bun.build({ + entrypoints: [values.entry], + outdir: values.outdir, + minify: values.minify, + sourcemap: values.sourcemap as any, + external: [...Object.keys(pkg.peerDependencies), pkg.name], + target: "bun", +}); + +console.log(`✅ Built to ${values.outdir}`); \ No newline at end of file diff --git a/src/bun/api/hooks/app.ts b/src/packages/gameflow-sdk/hooks/app.ts similarity index 88% rename from src/bun/api/hooks/app.ts rename to src/packages/gameflow-sdk/hooks/app.ts index bd549ca..d8fef7a 100644 --- a/src/bun/api/hooks/app.ts +++ b/src/packages/gameflow-sdk/hooks/app.ts @@ -3,7 +3,7 @@ import EmulatorHooks from "./emulators"; import GameHooks from "./games"; import StoreHooks from "./store"; -export default class GameflowHooks +export class GameflowHooks { games = new GameHooks(); emulators = new EmulatorHooks(); diff --git a/src/bun/api/hooks/auth.ts b/src/packages/gameflow-sdk/hooks/auth.ts similarity index 81% rename from src/bun/api/hooks/auth.ts rename to src/packages/gameflow-sdk/hooks/auth.ts index 992d91e..cb8dd1b 100644 --- a/src/bun/api/hooks/auth.ts +++ b/src/packages/gameflow-sdk/hooks/auth.ts @@ -1,5 +1,6 @@ -import { DownloadFileEntry } from "@/shared/types"; + import { AsyncSeriesHook } from "tapable"; +import { DownloadFileEntry } from "../shared"; export default class AuthHooks { diff --git a/src/bun/api/hooks/emulators.ts b/src/packages/gameflow-sdk/hooks/emulators.ts similarity index 83% rename from src/bun/api/hooks/emulators.ts rename to src/packages/gameflow-sdk/hooks/emulators.ts index 402bb21..768e56f 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/packages/gameflow-sdk/hooks/emulators.ts @@ -1,5 +1,6 @@ -import { EmulatorPostInstallContext } from "@/bun/types/types"; -import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "@/shared/types"; + +import { EmulatorPostInstallContextType } from "../index"; +import { DownloadFileEntry, EmulatorSourceEntryType, EmulatorSystem } from "../shared"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; export default class EmulatorHooks @@ -13,7 +14,7 @@ export default class EmulatorHooks /** * Triggered when emulator is downloaded or updated */ - emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']); + emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContextType], { emulator: string; }>(['ctx']); findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']); findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); @@ -24,7 +25,7 @@ export default class EmulatorHooks { return { ...tap, - fn: async (ctx: EmulatorPostInstallContext, ...rest: any[]) => + fn: async (ctx: EmulatorPostInstallContextType, ...rest: any[]) => { if (ctx.emulator === tap.emulator) { diff --git a/src/bun/api/hooks/games.ts b/src/packages/gameflow-sdk/hooks/games.ts similarity index 92% rename from src/bun/api/hooks/games.ts rename to src/packages/gameflow-sdk/hooks/games.ts index bb1f4bc..9083e47 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/packages/gameflow-sdk/hooks/games.ts @@ -1,6 +1,6 @@ -import { EmulatorPackageType, GameListFilterType } from '@/shared/constants'; -import { CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '@/shared/types'; -import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, Hook, AsyncSeriesWaterfallHook } from 'tapable'; + +import { EmulatorPackageType, GameListFilterType, CommandEntry, DownloadInfo, EmulatorSourceEntryType, EmulatorSupport, EmulatorSystem, FrontEndCollection, FrontEndFilterSets, FrontEndGameType, FrontEndGameTypeDetailed, FrontEndGameTypeWithIds, FrontEndId, FrontEndPlatformType, GameLookup, SaveFileChange, SaveSlots } from '../shared'; +import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } from 'tapable'; export default class GameHooks { diff --git a/src/bun/api/hooks/store.ts b/src/packages/gameflow-sdk/hooks/store.ts similarity index 86% rename from src/bun/api/hooks/store.ts rename to src/packages/gameflow-sdk/hooks/store.ts index b08cee5..c7f43e5 100644 --- a/src/bun/api/hooks/store.ts +++ b/src/packages/gameflow-sdk/hooks/store.ts @@ -1,5 +1,4 @@ -import { EmulatorDownloadInfoType } from "@/shared/constants"; -import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed } from "@/shared/types"; +import { FrontEndEmulator, FrontEndEmulatorDetailed, FrontEndGameTypeDetailed, EmulatorDownloadInfoType } from "../shared"; import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; export default class StoreHooks diff --git a/src/bun/types/types.schema.ts b/src/packages/gameflow-sdk/index.ts similarity index 72% rename from src/bun/types/types.schema.ts rename to src/packages/gameflow-sdk/index.ts index c4738fc..4149891 100644 --- a/src/bun/types/types.schema.ts +++ b/src/packages/gameflow-sdk/index.ts @@ -1,7 +1,20 @@ import z from "zod"; -import GameflowHooks from "../api/hooks/app"; -import Conf from "conf"; +import { GameflowHooks } from "./hooks/app"; +import { EmulatorDownloadInfoSchema, EmulatorPackageSchema, FrontendNotification, SettingsType } from "./shared"; import { $ZodRegistry } from "zod/v4/core"; +import Conf from "conf"; +import { EventEmitter } from 'node:events'; +import { TaskQueue } from "./task-queue"; + +export * from "./hooks/app"; +export * from "./task-queue"; + +export interface AppEventMap +{ + exitapp: []; + notification: [FrontendNotification]; + focus: []; +} export const PluginContextSchema = z.object({ hooks: z.instanceof(GameflowHooks) @@ -10,16 +23,22 @@ export const PluginContextSchema = z.object({ export const PluginLoadingContextSchema = z.object({ setProgress: z.function().input([z.number(), z.string()]).output(z.void()), config: z.instanceof(Conf).describe("Per plugin config. It will use the settings schema defined in the plugin class"), - zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI") + zodRegistry: z.instanceof($ZodRegistry).describe("Used by the settings to register metadata for the UI"), + app: z.object({ + config: z.instanceof(Conf), + events: z.instanceof(EventEmitter), + taskQueue: z.instanceof(TaskQueue) + }) }).extend(PluginContextSchema.shape); export const PluginDescriptionSchema = z.object({ name: z.string(), - displayName: z.string(), + displayName: z.string().optional(), version: z.string(), - description: z.string(), + description: z.string().optional(), icon: z.url().optional().describe("Can be an external URL to an image or a data url"), keywords: z.array(z.string()).optional(), + peerDependencies: z.record(z.string(), z.string()).optional(), category: z.string().default("other"), main: z.string().describe("The main entry. It must export a default class implementing PluginType"), canDisable: z.boolean().default(true).optional().describe("Can the plugin be disabled or enabled by the user") @@ -42,16 +61,6 @@ export const PluginSchema = z.object({ }).or(z.record(z.string(), z.any()))).optional() }); -export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { - load: (ctx: PluginLoadingContextType) => Promise; - settingsMigrations?: Record) => void>; -}; -export type PluginContextType = z.infer; -export type PluginLoadingContextType = Record> = z.infer & { - config: Conf; -}; -export type PluginDescriptionType = z.infer; - export const ActiveGameSchema = z.object({ process: z.any().optional(), gameId: z.object({ id: z.string(), source: z.string() }), @@ -60,4 +69,24 @@ export const ActiveGameSchema = z.object({ name: z.string(), command: z.object({ command: z.string().or(z.string().array()), startDir: z.string().optional() }) }); -export type ActiveGameType = z.infer; \ No newline at end of file + +export const EmulatorPostInstallContextSchema = z.object({ + emulator: z.string(), + emulatorPackage: EmulatorPackageSchema.optional(), + path: z.string(), + update: z.boolean(), + info: EmulatorDownloadInfoSchema, +}); + +export type ActiveGameType = z.infer; +export type PluginDescriptionType = z.infer; +export type PluginContextType = z.infer; +export type PluginLoadingContextType = Record> = z.infer & { + config: Conf; +}; +export type PluginType = Record> = Omit, "load" | 'settingsMigrations'> & { + load: (ctx: PluginLoadingContextType) => Promise; + settingsMigrations?: Record) => void>; +}; +export type EmulatorPostInstallContextType = z.infer; + diff --git a/src/packages/gameflow-sdk/package.json b/src/packages/gameflow-sdk/package.json new file mode 100644 index 0000000..7e51c16 --- /dev/null +++ b/src/packages/gameflow-sdk/package.json @@ -0,0 +1,51 @@ +{ + "name": "@simeonradivoev/gameflow-sdk", + "version": "1.5.3", + "types": "index.d.ts", + "description": "plugin SDK for the Gameflow Deck Launcher", + "exports": { + ".": "./index.ts", + "./shared": "./shared.ts" + }, + "bin": { + "gameflow-build": "build.ts" + }, + "peerDependencies": { + "7zip-bin": "^5.2.0", + "@auth/core": "^0.34.3", + "@elysiajs/cors": "^1.4.2", + "@elysiajs/eden": "^1.4.9", + "@jimp/wasm-webp": "^1.6.1", + "@phalcode/ts-igdb-client": "^1.0.26", + "cheerio": "^1.2.0", + "conf": "^15.1.0", + "drizzle-orm": "^0.45.2", + "elysia": "^1.4.28", + "fs-extra": "^11.3.5", + "get-folder-size": "^5.0.0", + "ini": "^6.0.0", + "jimp": "^1.6.1", + "mustache": "^4.2.0", + "node-7z": "^3.0.0", + "node-disk-info": "^1.3.0", + "node-downloader-helper": "^2.1.11", + "node-stream-zip": "^1.15.0", + "node-unrar-js": "^2.0.2", + "open": "^11.0.0", + "p-queue": "^9.2.0", + "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", + "systeminformation": "^5.31.5", + "tapable": "^2.3.3", + "tough-cookie": "^6.0.1", + "tough-cookie-file-store": "^3.3.0", + "unzip-stream": "^0.3.4", + "webview-bun": "^2.4.0", + "zod": "^4.4.3" + }, + "keywords": [ + "gameflow", + "sdk" + ] +} \ No newline at end of file diff --git a/scripts/sdk/sdk.tsconfig.json b/src/packages/gameflow-sdk/sdk.tsconfig.json similarity index 52% rename from scripts/sdk/sdk.tsconfig.json rename to src/packages/gameflow-sdk/sdk.tsconfig.json index 8da436e..707544a 100644 --- a/scripts/sdk/sdk.tsconfig.json +++ b/src/packages/gameflow-sdk/sdk.tsconfig.json @@ -17,26 +17,6 @@ "outDir": "../../dist-sdk", "types": [ "node" - ], - "paths": { - "@/*": [ - "../../src/*" - ], - "~/*": [ - "../../*" - ], - "@shared/*": [ - "../../src/shared/*" - ], - "@clients/*": [ - "../../src/clients/*" - ], - "@schema/*": [ - "../../src/bun/api/schema/*" - ], - "@queries/*": [ - "../../src/mainview/scripts/queries/*" - ] - } + ] } } \ No newline at end of file diff --git a/src/packages/gameflow-sdk/shared.ts b/src/packages/gameflow-sdk/shared.ts new file mode 100644 index 0000000..147db36 --- /dev/null +++ b/src/packages/gameflow-sdk/shared.ts @@ -0,0 +1,631 @@ +import * as z from "zod"; + +export const settingRegistry = z.registry<{ + dev?: boolean; +}>(); + +export const SettingsSchema = z.object({ + rommAddress: z.url().optional(), + rommUser: z.string().default('admin').optional(), + windowSize: z.object({ width: z.number(), height: z.number() }).optional(), + windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), + downloadPath: z.string(), + launchInFullscreen: z.boolean().default(true), + disabledPlugins: z.array(z.string()).default([]), + emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'), + emulatorWidescreen: z.boolean().default(true) +}); export const LocalSettingsSchema = z.object({ + backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }), + backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }), + theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }), + soundEffects: z.boolean().default(true).meta({ title: "Sounds" }), + soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }), + hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }), + showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }), + showQueryDevOptions: z.boolean().default(false).meta({ title: "Show Query Options" }).register(settingRegistry, { dev: true }), + useGameflowKeyboard: z.boolean().default(true).describe("Show the gameflow on screen keyboard when using a controller").meta({ title: "Use Gameflow Keyboard" }), + autoKeybaord: z.boolean().default(true).describe("Open on screen keybaord automatically").meta({ title: "Auto Keyboard" }) +}); +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(), + collection_source: z.string().optional(), + limit: z.coerce.number().optional(), + search: z.string().optional(), + offset: z.coerce.number().optional(), + source: z.string().optional(), + localOnly: z.coerce.boolean().optional(), + orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(), + age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), + keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), +}); +export const DownloadSourceSchema = z.object({ + id: z.string(), + name: z.string() +}); +export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); +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 const GithubManifestSchema = z.object({ + sha: z.hash('sha1'), + url: z.url(), + tree: z.array(z.object({ + path: z.string(), + mode: z.string(), + type: z.enum(['blob', 'tree']), + sha: z.hash('sha1'), + url: z.url() + })) +}); +export const StoreGameSaveSchema = z.object({ + cwd: z.string(), + globs: z.string().array() +}); +export const StoreDownloadSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('direct'), + url: z.url(), + name: z.string().optional(), + system: z.string(), + main: z.string().optional(), + saves: z.record(z.string(), StoreGameSaveSchema).optional() + }), + z.object({ + type: z.literal("itch"), + path: z.string(), + name: z.string().optional(), + system: z.string(), + saves: z.record(z.string(), StoreGameSaveSchema).optional() + }) +]); +export const NewGameSchema = z.object({ + name: z.string(), + summary: z.string(), + genres: z.string().regex(/^$|^(\s*\S[^,]*)(\s*,\s*\S[^,]*)*\s*$/, { + message: "Must be a comma-separated list", + }) +}); +export const StoreGameSchema = z.object({ + name: z.string(), + description: z.string(), + version: z.string(), + homepage: z.string().optional(), + keywords: z.string().array().optional(), + genres: z.string().array().optional(), + companies: z.string().array().optional(), + screenshots: z.string().array().optional(), + covers: z.string().array().optional(), + igdb_id: z.number().optional(), + ra_id: z.number().optional(), + sgdb_id: z.number().optional(), + first_release_date: z.union([z.number(), z.date()]).optional(), + player_count: z.string().optional(), + saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(), + downloads: z.record(z.string(), StoreDownloadSchema) +}); +export const EmulatorPackageSchema = z.object({ + name: z.string(), + description: z.string(), + homepage: z.url(), + logo: z.url(), + type: z.enum(['emulator']), + os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])), + keywords: z.array(z.string()).optional(), + downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [ + z.object({ + type: z.literal(['github', 'gitlab']), + pattern: z.string(), + path: z.string(), + bin: z.string().optional() + }), + z.object({ + type: z.literal('direct'), + url: z.url(), + bin: z.string().optional() + }), + z.object({ + type: z.literal('scoop'), + url: z.url(), + bin: z.string().optional() + }) + ]))).optional(), + systems: z.array(z.string()), + bios: z.literal(["required", "optional"]).optional() +}); +export const ScoopPackageSchema = z.object({ + version: z.string(), + url: z.url().optional(), + description: z.string(), + bin: z.string().optional(), + architecture: z.record(z.string(), z.object({ + url: z.url(), + hash: z.string().optional(), + extract_dir: z.string().optional() + })).optional() +}); +export const SystemInfoSchema = z.object({ + battery: z.object({ + percent: z.number(), + isCharging: z.boolean(), + acConnected: z.boolean(), + hasBattery: z.boolean() + }), + wifiConnections: z.array(z.object({ signalLevel: z.number() })), + bluetoothDevices: z.array(z.object({ connected: z.boolean() })) +}); +export const GithubReleaseSchema = z.object({ + id: z.number(), + tag_name: z.string().optional(), + url: z.url(), + body: z.string(), + assets: z.array(z.object({ + name: z.string(), + browser_download_url: z.url(), + content_type: z.string().optional() + })) +}); +export const EmulatorDownloadInfoSchema = z.object({ + id: z.string(), + version: z.string().optional(), + url: z.url().optional(), + description: z.string().optional(), + downloadDate: z.coerce.date(), + type: z.string() +}); +export const PluginEntrySchema = z.object({ + downloads: z.object({ + monthly: z.number(), + weekly: z.number() + }), + searchScore: z.number(), + installed: z.boolean(), + update: z.object({ from: z.string() }).optional(), + package: z.object({ + name: z.string(), + keywords: z.string().array(), + version: z.string(), + description: z.string().optional(), + sanitized_name: z.string(), + license: z.string().optional(), + publisher: z.object({ + email: z.string(), + username: z.string(), + trustedPublisher: z.object({ + id: z.string(), + oidcConfigId: z.string() + }).optional() + }), + date: z.coerce.date(), + links: z.object({ + homepage: z.string().optional(), + repository: z.string().optional(), + bugs: z.string().optional(), + npm: z.url() + }) + }) +}); +export const PluginBunDetailsSchema = z.object({ + name: z.string(), + keywords: z.string().array(), + version: z.string(), + author: z.object({ name: z.string().optional() }).optional(), + license: z.string().optional(), + devDependencies: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), + maintainers: z.object({ name: z.string() }).array().optional(), + dist: z.object({ unpackedSize: z.number() }), + description: z.string().optional(), + _npmUser: z.object({ name: z.string() }).optional() +}); +export type EmulatorPackageType = z.infer; +export type StoreGameType = z.infer; +export type StoreDownloadType = z.infer; +export type SettingsType = z.infer; +export type LocalSettingsType = z.infer; +export const PlatformSchema = z.object({ slug: z.string() }); +export type SystemInfoType = z.infer; +export type EmulatorDownloadInfoType = z.infer; +export type DownloadSourceType = z.infer; +export type PluginEntryType = z.infer; +export type PluginBunDetailsType = z.infer; + +export interface SaveFileChange +{ + subPath: string | string[]; + isGlob?: true; + cwd: string; + shared: boolean; + fixedSize?: boolean; +} + +export type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded'; + +export interface EmulatorSourceEntryType +{ + binPath: string; + rootPath?: string; + type: EmulatorSourceType; + exists: boolean; +} + +export interface FrontEndEmulator +{ + name: string; + source: string; + logo: string; + systems: EmulatorSystem[]; + description?: string; + gameCount: number; + validSources: EmulatorSourceEntryType[]; + integrations: EmulatorSupport[]; +} + +export interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } + +export interface FrontEndEmulatorDetailedDownload +{ + name: string; + type: string | undefined; + version?: string; +} + +export interface FrontEndEmulatorDetailed extends FrontEndEmulator +{ + homepage: string; + description: string; + downloads: FrontEndEmulatorDetailedDownload[]; + keywords?: string[]; + screenshots: string[]; + biosRequirement?: "required" | "optional"; + bios?: string[]; + storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; }; +} + +export interface FrontEndGameTypeDetailedAchievement +{ + id: string; + title: string; + description?: string; + date?: Date; + date_hardcode?: Date; + badge_url?: string; + display_order: number; + type?: string; +} + +export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator +{ + +} + +export interface FrontEndGameTypeDetailed extends Exclude +{ + summary: string | null; + fs_size_bytes: number | null; + missing: boolean; + local: boolean; + version?: string | null; + version_system?: string | null; + version_source?: string | null; + metadata: FrontEndGameMetadataDetailed, + emulators?: FrontEndGameTypeDetailedEmulator[], + achievements?: { + unlocked: number; + total: number; + entires: FrontEndGameTypeDetailedAchievement[]; + }; +}; + +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 FrontendNotification +{ + title?: string; + message: string; + type: 'success' | 'error' | 'info' | 'custom'; + icon?: "save" | "upload" | "clock"; + duration?: number; +} + +export interface CommandEntry +{ + /** The ID of the command. Could be just an index or a string */ + id: string | number; + /** The front end label for the command. Mainly gotten from ES-DE list */ + label?: string; + /** Compiled command to be executed */ + command: string | string[]; + /** Environment variables */ + env?: Record, + /** The path the spawned process will start at */ + startDir?: string; + /** Is the command valid, for example does the executable exists */ + valid: boolean; + /** Run the command as shell. Defaults is true */ + shell?: boolean; + /** For what emulator is the command */ + emulator?: string; + /** Where the emulator came from */ + emulatorSource?: EmulatorSourceType; + /** Metadata for the command */ + metadata: { + romPath?: string; + emulatorBin?: string; + /** The root directory of the emulator */ + emulatorDir?: string; + }; +} + +export interface FrontEndId +{ + id: string; + source: string; +} + +// Stuff stored in the local sqlite metadata field +export interface LocalGameMetadata +{ + genres?: string[], + companies?: string[], + game_modes?: string[], + age_ratings?: string[]; + player_count?: string; + first_release_date?: number; + average_rating?: number; +} + +export interface FrontEndPlatformType +{ + id: FrontEndId; + slug: string; + name: string; + family_name?: string | null; + path_cover: string | null; + game_count: number; + updated_at: Date; + hasLocal: boolean; + paths_screenshots: string[]; +} + +export interface FrontEndGameTypeWithIds extends FrontEndGameType +{ + igdb_id: number | null; + ra_id: number | null; +} + +export interface FrontEndFilterSets +{ + age_ratings: Set, + player_counts: Set, + languages: Set, + companies: Set, + genres: Set; +} + +export interface FrontEndFilterLists +{ + age_ratings: string[], + player_counts: string[], + languages: string[], + companies: string[], + genres: string[]; +} + +export interface FrontEndGameMetadata +{ + first_release_date: Date | null; +} + +export interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata +{ + genres: string[], + companies: string[], + game_modes: string[], + age_ratings: string[]; + player_count: string | null; + average_rating: number | null; +} + +export interface FrontEndGameType +{ + platform_display_name: string | null, + path_platform_cover: string | null; + id: FrontEndId, + source: string | null, + source_id: string | null, + path_fs: string | null, + path_covers: string[], + last_played: Date | null, + updated_at: Date, + metadata: FrontEndGameMetadata, + slug: string | null, + name: string | null, + platform_id: number | null, + platform_slug: string | null, + paths_screenshots: string[]; +}; + +export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued'; + +export interface GameInstallProgress +{ + progress?: number; + status?: GameStatusType; + details?: string; + commands?: CommandEntry[]; + error?: any; +} + +export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; +export type GameInstallProgressEvent = 'refresh'; + +export interface FrontendPlugin +{ + name: string; + displayName?: string; + description?: string; + category: string; + enabled: boolean; + canDisable: boolean; + canUninstall: boolean; + source: PluginSourceType; + hasSettings: boolean; + version: string; + icon?: string; + update?: PluginUpdateCheck; +} + +export interface PluginUpdateCheck +{ + current: string; + new: string; +} + +export type PluginSourceType = "builtin" | "store"; + +export type KeysWithValueAssignableTo = { + [K in keyof T]: Exclude extends Value ? K : never; +}[keyof T]; + +export interface DownloadInfo +{ + id: string; + screenshotUrls: string[]; + coverUrl: string; + platform?: DownloadPlatform; + slug?: string; + path_fs?: string; + main_glob?: string; + summary?: string; + name: string; + last_played?: Date; + igdb_id?: number; + ra_id?: number; + source_id: string; + system_slug: string; + extract_path?: string; + metadata?: any; + files: DownloadFileEntry[]; + auth?: string; + version?: string; + version_source?: string; + version_system?: string; +} + +export interface DownloadPlatform +{ + id: string; + source: string; + igdb_id?: number; + igdb_slug?: string; + ra_id?: number; + moby_id?: number; + slug: string; + name: string; + /** Like Sony or Nintendo */ + family_name?: string; +} + +export interface DownloadFileEntry +{ + url: URL; + /** The path of the file, excluding the name */ + file_path: string; + /** Just the name of the file including the extension */ + file_name: string; + /** Checksum of the file */ + sha1?: string; + /** Size in bytes */ + size?: number; +} + +export interface LocalDownloadFileEntry extends DownloadFileEntry +{ + /** Exists on the file system */ + exists: boolean; + /** Matches the checksum */ + matches: boolean; +} + +export interface FrontEndCollection +{ + id: FrontEndId; + name: string; + description: string; + path_platform_cover: string | null; + game_count: number; +} + +export type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config"; + +export interface EmulatorSupport +{ + id: string; + source?: EmulatorSourceEntryType; + supportLevel?: "partial" | "full"; + capabilities?: EmulatorCapabilities[]; +} + +export interface GameLookup +{ + source: string; + id: string; + coverUrl: string | null | undefined; + slug: string | null | undefined; + screenshotUrls: string[]; + name: string; + summary: string | null | undefined; + genres: string[]; + companies: string[]; + game_modes: string[]; + age_ratings: string[]; + player_count: string | undefined; + first_release_date: number | undefined; + average_rating: number | undefined; + keywords: string[]; + igdb_id: number | undefined; + platforms: { + id: number; + name?: string | null; + displayName: string; + slug: string; + }[]; +} + +export interface AutoSaveChange +{ + subPath: string; + cwd: string; +} + +export type SaveSlots = Record; diff --git a/src/bun/api/task-queue.ts b/src/packages/gameflow-sdk/task-queue.ts similarity index 99% rename from src/bun/api/task-queue.ts rename to src/packages/gameflow-sdk/task-queue.ts index 97e783d..b86aab6 100644 --- a/src/bun/api/task-queue.ts +++ b/src/packages/gameflow-sdk/task-queue.ts @@ -1,6 +1,7 @@ -import { JobStatus } from '@/shared/types'; + import EventEmitter from 'node:events'; import z from 'zod'; +import { JobStatus } from './shared'; export class TaskQueue { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5eb5fdf..3c9d776 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,5 +1,3 @@ -import * as z from 'zod'; - export const LOGIN_PORT = 5196; export const OAUTH_REDIRECT_PORT = 5194; export const SERVER_PORT = 5173; @@ -10,211 +8,6 @@ export const RPC_PORT = 8787; export const RPC_URL = (host: string) => `http://${host}:${RPC_PORT}`; export const EMULATORJS_URL = (host: string) => `http://${host}:${EMULATORJS_PORT}`; export const SOCKETS_URL = (host: string) => `ws://${host}:${RPC_PORT}`; -export const settingRegistry = z.registry<{ - dev?: boolean; -}>(); export const DefaultRommStaleTime = 60 * 1000; // A minute - -export const SettingsSchema = z.object({ - rommAddress: z.url().optional(), - rommUser: z.string().default('admin').optional(), - windowSize: z.object({ width: z.number(), height: z.number() }).optional(), - windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), - downloadPath: z.string(), - launchInFullscreen: z.boolean().default(true), - disabledPlugins: z.array(z.string()).default([]), - emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'), - emulatorWidescreen: z.boolean().default(true) -}); - -export const LocalSettingsSchema = z.object({ - backgroundBlur: z.boolean().default(true).meta({ title: "Background Blur" }), - backgroundAnimation: z.boolean().default(true).meta({ title: "Background Animation" }), - theme: z.enum(['dark', 'light', 'auto']).default('auto').meta({ title: "Theme" }), - soundEffects: z.boolean().default(true).meta({ title: "Sounds" }), - soundEffectsVolume: z.number().min(0).max(100).default(50).meta({ title: "Sound Volume" }), - hapticsEffects: z.boolean().default(true).meta({ title: "Haptics" }), - showRouterDevOptions: z.boolean().default(false).meta({ title: "Show Router Options" }).register(settingRegistry, { dev: true }), - showQueryDevOptions: z.boolean().default(false).meta({ title: "Show Query Options" }).register(settingRegistry, { dev: true }), - useGameflowKeyboard: z.boolean().default(true).describe("Show the gameflow on screen keyboard when using a controller").meta({ title: "Use Gameflow Keyboard" }), - autoKeybaord: z.boolean().default(true).describe("Open on screen keybaord automatically").meta({ title: "Auto Keyboard" }) -}); - -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(), - collection_source: z.string().optional(), - limit: z.coerce.number().optional(), - search: z.string().optional(), - offset: z.coerce.number().optional(), - source: z.string().optional(), - localOnly: z.coerce.boolean().optional(), - orderBy: z.literal(['added', 'activity', 'name', 'release']).optional(), - age_ratings: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), - genres: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), - keywords: z.union([z.string().array(), z.string().transform(v => [v])]).optional(), -}); - -export const DownloadSourceSchema = z.object({ - id: z.string(), - name: z.string() -}); - -export const RommLoginDataSchema = z.object({ hostname: z.url(), username: z.string(), password: z.string() }); - -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 const GithubManifestSchema = z.object({ - sha: z.hash('sha1'), - url: z.url(), - tree: z.array(z.object({ - path: z.string(), - mode: z.string(), - type: z.enum(['blob', 'tree']), - sha: z.hash('sha1'), - url: z.url() - })) -}); - -export const StoreGameSaveSchema = z.object({ - cwd: z.string(), - globs: z.string().array() -}); - -export const StoreDownloadSchema = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('direct'), - url: z.url(), - name: z.string().optional(), - system: z.string(), - main: z.string().optional(), - saves: z.record(z.string(), StoreGameSaveSchema).optional() - }), - z.object({ - type: z.literal("itch"), - path: z.string(), - name: z.string().optional(), - system: z.string(), - saves: z.record(z.string(), StoreGameSaveSchema).optional() - }) -]); - -export const NewGameSchema = z.object({ - name: z.string(), - summary: z.string(), - genres: z.string().regex(/^$|^(\s*\S[^,]*)(\s*,\s*\S[^,]*)*\s*$/, { - message: "Must be a comma-separated list", - }) -}); - -export const StoreGameSchema = z.object({ - name: z.string(), - description: z.string(), - version: z.string(), - homepage: z.string().optional(), - keywords: z.string().array().optional(), - genres: z.string().array().optional(), - companies: z.string().array().optional(), - screenshots: z.string().array().optional(), - covers: z.string().array().optional(), - igdb_id: z.number().optional(), - ra_id: z.number().optional(), - sgdb_id: z.number().optional(), - first_release_date: z.union([z.number(), z.date()]).optional(), - player_count: z.string().optional(), - saves: z.record(z.string(), z.record(z.string(), StoreGameSaveSchema)).optional(), - downloads: z.record(z.string(), StoreDownloadSchema) -}); - -export const EmulatorPackageSchema = z.object({ - name: z.string(), - description: z.string(), - homepage: z.url(), - logo: z.url(), - type: z.enum(['emulator']), - os: z.array(z.enum(['darwin', 'linux', 'win32', 'android'])), - keywords: z.array(z.string()).optional(), - downloads: z.record(z.string(), z.array(z.discriminatedUnion('type', [ - z.object({ - type: z.literal(['github', 'gitlab']), - pattern: z.string(), - path: z.string(), - bin: z.string().optional() - }), - z.object({ - type: z.literal('direct'), - url: z.url(), - bin: z.string().optional() - }), - z.object({ - type: z.literal('scoop'), - url: z.url(), - bin: z.string().optional() - }) - ]))).optional(), - systems: z.array(z.string()), - bios: z.literal(["required", "optional"]).optional() -}); - -export const ScoopPackageSchema = z.object({ - version: z.string(), - url: z.url().optional(), - description: z.string(), - bin: z.string().optional(), - architecture: z.record(z.string(), z.object({ - url: z.url(), - hash: z.string().optional(), - extract_dir: z.string().optional() - })).optional() -}); - -export const SystemInfoSchema = z.object({ - battery: z.object({ - percent: z.number(), - isCharging: z.boolean(), - acConnected: z.boolean(), - hasBattery: z.boolean() - - }), - wifiConnections: z.array(z.object({ signalLevel: z.number() })), - bluetoothDevices: z.array(z.object({ connected: z.boolean() })) -}); - -export const GithubReleaseSchema = z.object({ - id: z.number(), - tag_name: z.string().optional(), - url: z.url(), - body: z.string(), - assets: z.array(z.object({ - name: z.string(), - browser_download_url: z.url(), - content_type: z.string().optional() - })) -}); - -export const EmulatorDownloadInfoSchema = z.object({ - id: z.string(), - version: z.string().optional(), - url: z.url().optional(), - description: z.string().optional(), - downloadDate: z.coerce.date(), - type: z.string() -}); - -export type EmulatorPackageType = z.infer; -export type StoreGameType = z.infer; -export type StoreDownloadType = z.infer; -export type SettingsType = z.infer; -export type LocalSettingsType = z.infer; -export const PlatformSchema = z.object({ slug: z.string() }); -export type SystemInfoType = z.infer; -export type EmulatorDownloadInfoType = z.infer; -export type DownloadSourceType = z.infer; +export const PluginRegistry = process.env.STORE_REGISTRY ?? "https://registry.npmjs.org"; \ No newline at end of file diff --git a/src/shared/types.schema.ts b/src/shared/types.schema.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/types.ts b/src/shared/types.ts index dc72a7a..e69de29 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,387 +0,0 @@ -export interface SaveFileChange -{ - subPath: string | string[]; - isGlob?: true; - cwd: string; - shared: boolean; - fixedSize?: boolean; -} - -export type EmulatorSourceType = 'custom' | 'store' | 'registry' | 'system' | 'static' | 'embedded'; - -export interface EmulatorSourceEntryType -{ - binPath: string; - rootPath?: string; - type: EmulatorSourceType; - exists: boolean; -} - -export interface FrontEndEmulator -{ - name: string; - source: string; - logo: string; - systems: EmulatorSystem[]; - description?: string; - gameCount: number; - validSources: EmulatorSourceEntryType[]; - integrations: EmulatorSupport[]; -} - -export interface EmulatorSystem { id: string, romm_slug?: string, name: string, iconUrl: string; } - -export interface FrontEndEmulatorDetailedDownload -{ - name: string; - type: string | undefined; - version?: string; -} - -export interface FrontEndEmulatorDetailed extends FrontEndEmulator -{ - homepage: string; - description: string; - downloads: FrontEndEmulatorDetailedDownload[]; - keywords?: string[]; - screenshots: string[]; - biosRequirement?: "required" | "optional"; - bios?: string[]; - storeDownloadInfo?: { hasUpdate: boolean; version?: string, type: string; description?: string; }; -} - -export interface FrontEndGameTypeDetailedAchievement -{ - id: string; - title: string; - description?: string; - date?: Date; - date_hardcode?: Date; - badge_url?: string; - display_order: number; - type?: string; -} - -export interface FrontEndGameTypeDetailedEmulator extends FrontEndEmulator -{ - -} - -export interface FrontEndGameTypeDetailed extends Exclude -{ - summary: string | null; - fs_size_bytes: number | null; - missing: boolean; - local: boolean; - version?: string | null; - version_system?: string | null; - version_source?: string | null; - metadata: FrontEndGameMetadataDetailed, - emulators?: FrontEndGameTypeDetailedEmulator[], - achievements?: { - unlocked: number; - total: number; - entires: FrontEndGameTypeDetailedAchievement[]; - }; -}; - -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 FrontendNotification -{ - title?: string; - message: string; - type: 'success' | 'error' | 'info' | 'custom'; - icon?: "save" | "upload" | "clock"; - duration?: number; -} - -export interface CommandEntry -{ - /** The ID of the command. Could be just an index or a string */ - id: string | number; - /** The front end label for the command. Mainly gotten from ES-DE list */ - label?: string; - /** Compiled command to be executed */ - command: string | string[]; - /** Environment variables */ - env?: Record, - /** The path the spawned process will start at */ - startDir?: string; - /** Is the command valid, for example does the executable exists */ - valid: boolean; - /** Run the command as shell. Defaults is true */ - shell?: boolean; - /** For what emulator is the command */ - emulator?: string; - /** Where the emulator came from */ - emulatorSource?: EmulatorSourceType; - /** Metadata for the command */ - metadata: { - romPath?: string; - emulatorBin?: string; - /** The root directory of the emulator */ - emulatorDir?: string; - }; -} - -export interface FrontEndId -{ - id: string; - source: string; -} - -// Stuff stored in the local sqlite metadata field -export interface LocalGameMetadata -{ - genres?: string[], - companies?: string[], - game_modes?: string[], - age_ratings?: string[]; - player_count?: string; - first_release_date?: number; - average_rating?: number; -} - -export interface FrontEndPlatformType -{ - id: FrontEndId; - slug: string; - name: string; - family_name?: string | null; - path_cover: string | null; - game_count: number; - updated_at: Date; - hasLocal: boolean; - paths_screenshots: string[]; -} - -export interface FrontEndGameTypeWithIds extends FrontEndGameType -{ - igdb_id: number | null; - ra_id: number | null; -} - -export interface FrontEndFilterSets -{ - age_ratings: Set, - player_counts: Set, - languages: Set, - companies: Set, - genres: Set; -} - -export interface FrontEndFilterLists -{ - age_ratings: string[], - player_counts: string[], - languages: string[], - companies: string[], - genres: string[]; -} - -export interface FrontEndGameMetadata -{ - first_release_date: Date | null; -} - -export interface FrontEndGameMetadataDetailed extends FrontEndGameMetadata -{ - genres: string[], - companies: string[], - game_modes: string[], - age_ratings: string[]; - player_count: string | null; - average_rating: number | null; -} - -export interface FrontEndGameType -{ - platform_display_name: string | null, - path_platform_cover: string | null; - id: FrontEndId, - source: string | null, - source_id: string | null, - path_fs: string | null, - path_covers: string[], - last_played: Date | null, - updated_at: Date, - metadata: FrontEndGameMetadata, - slug: string | null, - name: string | null, - platform_id: number | null, - platform_slug: string | null, - paths_screenshots: string[]; -}; - -export type GameStatusType = 'installed' | 'missing-emulator' | 'error' | 'install' | 'download' | 'extract' | 'playing' | 'queued'; - -export interface GameInstallProgress -{ - progress?: number; - status?: GameStatusType; - details?: string; - commands?: CommandEntry[]; - error?: any; -} - -export type JobStatus = 'completed' | 'error' | 'running' | 'queued' | 'aborted'; -export type GameInstallProgressEvent = 'refresh'; - -export interface FrontendPlugin -{ - name: string; - displayName: string; - description: string; - category: string; - enabled: boolean; - canDisable: boolean; - source: PluginSourceType; - hasSettings: boolean; - version: string; - icon?: string; -} - -export type PluginSourceType = "builtin"; - -export type KeysWithValueAssignableTo = { - [K in keyof T]: Exclude extends Value ? K : never; -}[keyof T]; - -export interface DownloadInfo -{ - id: string; - screenshotUrls: string[]; - coverUrl: string; - platform?: DownloadPlatform; - slug?: string; - path_fs?: string; - main_glob?: string; - summary?: string; - name: string; - last_played?: Date; - igdb_id?: number; - ra_id?: number; - source_id: string; - system_slug: string; - extract_path?: string; - metadata?: any; - files: DownloadFileEntry[]; - auth?: string; - version?: string; - version_source?: string; - version_system?: string; -} - -export interface DownloadPlatform -{ - id: string; - source: string; - igdb_id?: number; - igdb_slug?: string; - ra_id?: number; - moby_id?: number; - slug: string; - name: string; - /** Like Sony or Nintendo */ - family_name?: string; -} - -export interface DownloadFileEntry -{ - url: URL; - /** The path of the file, excluding the name */ - file_path: string; - /** Just the name of the file including the extension */ - file_name: string; - /** Checksum of the file */ - sha1?: string; - /** Size in bytes */ - size?: number; -} - -export interface LocalDownloadFileEntry extends DownloadFileEntry -{ - /** Exists on the file system */ - exists: boolean; - /** Matches the checksum */ - matches: boolean; -} - -export interface FrontEndCollection -{ - id: FrontEndId; - name: string; - description: string; - path_platform_cover: string | null; - game_count: number; -} - -export type EmulatorCapabilities = "saves" | "fullscreen" | "resolution" | "batch" | "states" | "config"; - -export interface EmulatorSupport -{ - id: string; - source?: EmulatorSourceEntryType; - supportLevel?: "partial" | "full"; - capabilities?: EmulatorCapabilities[]; -} - -export interface GameLookup -{ - source: string; - id: string; - coverUrl: string | null | undefined; - slug: string | null | undefined; - screenshotUrls: string[]; - name: string; - summary: string | null | undefined; - genres: string[]; - companies: string[]; - game_modes: string[]; - age_ratings: string[]; - player_count: string | undefined; - first_release_date: number | undefined; - average_rating: number | undefined; - keywords: string[]; - igdb_id: number | undefined; - platforms: { - id: number; - name?: string | null; - displayName: string; - slug: string; - }[]; -} - -export interface AutoSaveChange -{ - subPath: string; - cwd: string; -} - -export type SaveSlots = Record; \ No newline at end of file diff --git a/src/tests/downloads.test.ts b/src/tests/downloads.test.ts index 6a58e55..7be5dd0 100644 --- a/src/tests/downloads.test.ts +++ b/src/tests/downloads.test.ts @@ -4,7 +4,7 @@ import * as app from '@/bun/api/app'; import fs from 'node:fs/promises'; import path from "node:path"; import AdmZip from "adm-zip"; -import { DownloadInfo } from '@/shared/types'; +import { DownloadInfo } from '@simeonradivoev/gameflow-sdk/shared'; describe("Download Tests", () => { diff --git a/tsconfig.json b/tsconfig.json index 7fb79f1..500e8f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "noUnusedLocals": true, "noFallthroughCasesInSwitch": true, "paths": { + "@simeonradivoev/gameflow-sdk/*": ["./src/packages/gameflow-sdk/*"], "@/*": [ "./src/*" ],