From c09fbd3dc88891227eda2b9f3bd9ac45621c00ea Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Fri, 17 Apr 2026 21:21:14 +0300 Subject: [PATCH] fix: Fixed tests feat: Added RClone integration feat: Implemented plugin settings feat: Updated minimal store version test: Fixed tests feat: Moved store and igdb and es-de to their own plugins --- bun.lock | 27 +- drizzle/0002_flowery_rocket_raccoon.sql | 31 ++ drizzle/meta/0002_snapshot.json | 479 ++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 5 +- scripts/dev.ts | 7 +- .../drizzle/es-de/0000_sparkling_banshee.sql | 34 ++ scripts/drizzle/es-de/meta/0000_snapshot.json | 234 ++++++++ scripts/drizzle/es-de/meta/_journal.json | 13 + scripts/generate-es-de-mapping.ts | 18 +- src/bun/api/app.ts | 8 +- src/bun/api/emulatorjs/emulatorjs.ts | 6 +- src/bun/api/games/games.ts | 464 +++++++--------- src/bun/api/games/platforms.ts | 38 +- .../api/games/services/launchGameService.ts | 401 +------------- src/bun/api/games/services/statusService.ts | 177 +++--- src/bun/api/games/services/utils.ts | 115 +--- src/bun/api/hooks/app.ts | 2 + src/bun/api/hooks/emulators.ts | 2 + src/bun/api/hooks/games.ts | 42 +- src/bun/api/hooks/store.ts | 10 + src/bun/api/jobs/install-job.ts | 85 ++- src/bun/api/jobs/jobs.ts | 2 + src/bun/api/jobs/launch-game-job.ts | 26 +- src/bun/api/jobs/reload-plugins-job.ts | 15 + .../com.simeonradivoev.gameflow.cemu/cemu.ts | 6 +- .../package.json | 1 + .../dolphin.ts | 16 +- .../package.json | 1 + .../utils.ts | 8 +- .../package.json | 1 + .../pcsx2.ts | 6 +- .../package.json | 1 + .../ppsspp.ts | 6 +- .../package.json | 1 + .../com.simeonradivoev.gameflow.xemu/xemu.ts | 4 +- .../package.json | 1 + .../xenia.ts | 9 +- .../com.simeonradivoev.gameflow.es/es-de.ts | 520 ++++++++++++++++++ .../package.json | 13 + .../package.json | 13 + .../rclone.ts | 292 ++++++++++ .../com.simeonradivoev.gameflow.igdb/igdb.ts | 83 +++ .../package.json | 13 + .../package.json | 1 + .../com.simeonradivoev.gameflow.romm/romm.ts | 144 +++-- .../package.json | 13 + .../services.ts | 313 +++++++++++ .../store.ts | 312 +++++++++++ src/bun/api/plugins/plugin-manager.ts | 62 ++- src/bun/api/plugins/plugins.ts | 19 +- src/bun/api/plugins/register-plugins.ts | 22 +- src/bun/api/schema/app.ts | 6 +- src/bun/api/settings/services.ts | 18 +- src/bun/api/settings/settings.ts | 52 +- .../api/store/services/emulatorsService.ts | 54 +- src/bun/api/store/services/gamesService.ts | 67 +-- src/bun/api/store/store.ts | 120 ++-- src/bun/api/system.ts | 83 ++- src/bun/api/task-queue.ts | 13 +- src/bun/types/typesc.schema.ts | 36 +- src/mainview/App.tsx | 1 - src/mainview/components/AppCommunication.tsx | 30 +- src/mainview/components/CardElement.tsx | 26 +- src/mainview/components/CardList.tsx | 8 +- src/mainview/components/CollectionList.tsx | 3 +- src/mainview/components/CollectionsDetail.tsx | 6 +- src/mainview/components/Constants.tsx | 16 +- src/mainview/components/FrontEndGameCard.tsx | 28 +- src/mainview/components/GameList.tsx | 32 +- src/mainview/components/Header.tsx | 14 +- .../components/ImageWithFallbacks.tsx | 19 + src/mainview/components/LoadingCardList.tsx | 1 - src/mainview/components/LoadingScreen.tsx | 9 + src/mainview/components/PlatformsList.tsx | 58 +- src/mainview/components/SelectMenu.tsx | 18 +- src/mainview/components/game/ActionButton.tsx | 4 +- .../components/game/ActionButtons.tsx | 46 +- src/mainview/components/game/Details.tsx | 2 +- src/mainview/components/game/MainActions.tsx | 5 +- .../components/options/OptionInput.tsx | 3 +- .../components/options/OptionSpace.tsx | 5 + .../components/store/EmulatorsSection.tsx | 4 +- src/mainview/gen/routeTree.gen.ts | 21 + src/mainview/preload.tsx | 13 +- src/mainview/routes/embedded.$source.$id.tsx | 2 +- src/mainview/routes/game/$source.$id.tsx | 8 +- src/mainview/routes/index.tsx | 10 +- src/mainview/routes/platform.$source.$id.tsx | 68 ++- src/mainview/routes/settings/accounts.tsx | 6 +- src/mainview/routes/settings/emulators.tsx | 4 +- .../routes/settings/plugin.$source.tsx | 158 ++++++ src/mainview/routes/settings/plugins.tsx | 78 ++- src/mainview/routes/settings/route.tsx | 28 +- .../routes/store/details.emulator.$id.tsx | 5 +- src/mainview/routes/store/tab/games.tsx | 38 +- src/mainview/routes/store/tab/index.tsx | 20 +- src/mainview/routes/store/tab/route.tsx | 1 + src/mainview/scripts/queries/plugins.ts | 9 + src/mainview/scripts/queries/romm.ts | 51 +- src/mainview/scripts/queries/settings.ts | 53 +- src/mainview/scripts/queries/store.ts | 2 + src/mainview/scripts/queries/system.ts | 10 + src/shared/constants.ts | 56 +- src/shared/types..d.ts | 32 +- src/tests/game-launching.test.ts | 27 +- src/tests/mock-roms/mock-emulator.exe | 1 + src/tests/mock-roms/mock-rom.iso | 1 + src/tests/preload.ts | 1 + vendors/es-de/emulators.darwin.x64.sqlite | Bin 180224 -> 176128 bytes vendors/es-de/emulators.haiku.x64.sqlite | Bin 135168 -> 131072 bytes vendors/es-de/emulators.linux.arm.sqlite | Bin 184320 -> 180224 bytes vendors/es-de/emulators.linux.x64.sqlite | Bin 221184 -> 217088 bytes vendors/es-de/emulators.win32.x64.sqlite | Bin 212992 -> 212992 bytes vendors/romm/custom-overrides.json | 22 +- 115 files changed, 4139 insertions(+), 1502 deletions(-) create mode 100644 drizzle/0002_flowery_rocket_raccoon.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 scripts/drizzle/es-de/0000_sparkling_banshee.sql create mode 100644 scripts/drizzle/es-de/meta/0000_snapshot.json create mode 100644 scripts/drizzle/es-de/meta/_journal.json create mode 100644 src/bun/api/hooks/store.ts create mode 100644 src/bun/api/jobs/reload-plugins-job.ts create mode 100644 src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts create mode 100644 src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json create mode 100644 src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json create mode 100644 src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts create mode 100644 src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts create mode 100644 src/mainview/components/ImageWithFallbacks.tsx create mode 100644 src/mainview/components/LoadingScreen.tsx create mode 100644 src/mainview/routes/settings/plugin.$source.tsx create mode 100644 src/tests/mock-roms/mock-emulator.exe create mode 100644 src/tests/mock-roms/mock-rom.iso diff --git a/bun.lock b/bun.lock index 0b550d6..22e9e88 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@jimp/wasm-webp": "^1.6.0", + "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -25,6 +26,7 @@ "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "open": "^11.0.0", + "p-queue": "^9.1.2", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", @@ -32,7 +34,6 @@ "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", - "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", "zod": "^4.3.6", @@ -58,8 +59,10 @@ "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", "@types/ini": "^4.1.1", + "@types/json-schema": "^7.0.15", "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", + "@types/rclone.js": "^0.6.3", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", @@ -456,6 +459,10 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@phalcode/ts-apicalypse": ["@phalcode/ts-apicalypse@1.0.26", "", { "dependencies": { "axios": "^1.11.0" } }, "sha512-RdqkuunEYu63hRs4tYZ6FLTC17ynC6AJ/YUppRGSIyr6pm5pI/vB1qlEaeUr/f4JJsNmbFwGjnMJdXvoP1LmWA=="], + + "@phalcode/ts-igdb-client": ["@phalcode/ts-igdb-client@1.0.26", "", { "dependencies": { "@phalcode/ts-apicalypse": "^1.0.26", "axios": "^1.11.0" } }, "sha512-ITBazxhafHDBVJFI6THrLOT8OuO4zhD9pOeKQUFJ80soKhBevvbJz3tzkt24fF783Hoqaja8rWmGSwcN04d5gA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], @@ -634,6 +641,8 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/rclone.js": ["@types/rclone.js@0.6.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-BssKAAVRY//fxGKso8SatyOwiD7X0toDofNnVxZlIXmN7UHrn2UBTxldNAjgUvWA91qJyeEPfKmeJpZVhLugXg=="], + "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -692,7 +701,7 @@ "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], - "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], + "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], @@ -958,7 +967,7 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], @@ -1314,6 +1323,10 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-queue": ["p-queue@9.1.2", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw=="], + + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -1390,7 +1403,7 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], @@ -1638,10 +1651,6 @@ "trim-newlines": ["trim-newlines@3.0.1", "", {}, "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw=="], - "ts-apicalypse": ["ts-apicalypse@0.4.2", "", { "dependencies": { "axios": "^1.4.0" } }, "sha512-A02KFDFZHYTft0fTkxr5AERLb//XK/qjvGxrX8uNCwZAvFpLI/4TrOVxbq6kV2caZXWAn8eZZfRzVn1xd/cSMw=="], - - "ts-igdb-client": ["ts-igdb-client@0.4.2", "", { "dependencies": { "axios": "^1.4.0", "ts-apicalypse": "^0.4.2" } }, "sha512-VGQbIyy75GbHW0WGGHBXsdJzuj8wOW/jiWURmQWltpkFjCTMSqqx9gTPSUUcJ0W2kdlWcyMU4TDDHo4N3Fij7A=="], - "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1868,6 +1877,8 @@ "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "http-proxy/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "load-json-file/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], diff --git a/drizzle/0002_flowery_rocket_raccoon.sql b/drizzle/0002_flowery_rocket_raccoon.sql new file mode 100644 index 0000000..0d8fa7e --- /dev/null +++ b/drizzle/0002_flowery_rocket_raccoon.sql @@ -0,0 +1,31 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_games` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `source_id` text, + `source` text, + `igdb_id` integer, + `name` text, + `ra_id` integer, + `path_fs` text, + `main_glob` text, + `last_played` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `metadata` text DEFAULT '{}' NOT NULL, + `slug` text, + `platform_id` integer NOT NULL, + `cover` blob, + `type` text, + `summary` text, + `version` text, + `version_source` text, + `version_system` text, + FOREIGN KEY (`platform_id`) REFERENCES `platforms`(`id`) ON UPDATE cascade ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_games`("id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system") SELECT "id", "source_id", "source", "igdb_id", "name", "ra_id", "path_fs", "main_glob", "last_played", "created_at", "metadata", "slug", "platform_id", "cover", "type", "summary", "version", "version_source", "version_system" FROM `games`;--> statement-breakpoint +DROP TABLE `games`;--> statement-breakpoint +ALTER TABLE `__new_games` RENAME TO `games`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `games_igdb_id_unique` ON `games` (`igdb_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_ra_id_unique` ON `games` (`ra_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `games_slug_unique` ON `games` (`slug`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..fb182f2 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,479 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "40569ae5-facd-4680-bd48-fe70c5abf498", + "prevId": "6dc00b41-64d7-4cb3-ad90-f9e8e35af643", + "tables": { + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections_games": { + "name": "collections_games", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_games_collection_id_collections_id_fk": { + "name": "collections_games_collection_id_collections_id_fk", + "tableFrom": "collections_games", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "collections_games_game_id_games_id_fk": { + "name": "collections_games_game_id_games_id_fk", + "tableFrom": "collections_games", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "games": { + "name": "games", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path_fs": { + "name": "path_fs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "main_glob": { + "name": "main_glob", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_played": { + "name": "last_played", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform_id": { + "name": "platform_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_source": { + "name": "version_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_system": { + "name": "version_system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "games_igdb_id_unique": { + "name": "games_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "games_ra_id_unique": { + "name": "games_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "games_slug_unique": { + "name": "games_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "games_platform_id_platforms_id_fk": { + "name": "games_platform_id_platforms_id_fk", + "tableFrom": "games", + "tableTo": "platforms", + "columnsFrom": [ + "platform_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "platforms": { + "name": "platforms", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "igdb_id": { + "name": "igdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "igdb_slug": { + "name": "igdb_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moby_id": { + "name": "moby_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "es_slug": { + "name": "es_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ra_id": { + "name": "ra_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover": { + "name": "cover", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "platforms_igdb_id_unique": { + "name": "platforms_igdb_id_unique", + "columns": [ + "igdb_id" + ], + "isUnique": true + }, + "platforms_igdb_slug_unique": { + "name": "platforms_igdb_slug_unique", + "columns": [ + "igdb_slug" + ], + "isUnique": true + }, + "platforms_moby_id_unique": { + "name": "platforms_moby_id_unique", + "columns": [ + "moby_id" + ], + "isUnique": true + }, + "platforms_es_slug_unique": { + "name": "platforms_es_slug_unique", + "columns": [ + "es_slug" + ], + "isUnique": true + }, + "platforms_ra_id_unique": { + "name": "platforms_ra_id_unique", + "columns": [ + "ra_id" + ], + "isUnique": true + }, + "platforms_slug_unique": { + "name": "platforms_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "screenshots": { + "name": "screenshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "game_id": { + "name": "game_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "screenshots_game_id_games_id_fk": { + "name": "screenshots_game_id_games_id_fk", + "tableFrom": "screenshots", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c181729..0df44e3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1772998956867, "tag": "0001_outstanding_silk_fever", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1776111721964, + "tag": "0002_flowery_rocket_raccoon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 3d5f135..79ef69a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@elysiajs/cors": "^1.4.1", "@elysiajs/eden": "^1.4.6", "@jimp/wasm-webp": "^1.6.0", + "@phalcode/ts-igdb-client": "^1.0.26", "cheerio": "^1.2.0", "conf": "^15.0.2", "drizzle-orm": "^0.45.1", @@ -65,6 +66,7 @@ "node-stream-zip": "^1.15.0", "node-unrar-js": "^2.0.2", "open": "^11.0.0", + "p-queue": "^9.1.2", "pathe": "^2.0.3", "slugify": "^1.6.9", "smol-toml": "^1.6.1", @@ -72,7 +74,6 @@ "tapable": "^2.3.0", "tough-cookie": "^6.0.0", "tough-cookie-file-store": "^3.3.0", - "ts-igdb-client": "^0.4.2", "unzip-stream": "^0.3.4", "webview-bun": "^2.4.0", "zod": "^4.3.6" @@ -98,8 +99,10 @@ "@types/fs-extra": "^11.0.4", "@types/howler": "^2.2.12", "@types/ini": "^4.1.1", + "@types/json-schema": "^7.0.15", "@types/mustache": "^4.2.6", "@types/node-7z": "^2.1.11", + "@types/rclone.js": "^0.6.3", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@types/unzip-stream": "^0.3.4", diff --git a/scripts/dev.ts b/scripts/dev.ts index b7c07f5..b8d3f6d 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -20,7 +20,7 @@ function spawnServer () HEADLESS: "true", }, stdout: "pipe", - stderr: "inherit", + stderr: "pipe", stdin: "pipe", signal: abortController.signal, killSignal: 'SIGUSR1', @@ -40,6 +40,11 @@ function spawnServer () console.log(e); } }); + const rle = createInterface({ input: Readable.fromWeb(s.stderr as any) }); + rle.on('line', e => + { + console.error(e); + }); return s; } diff --git a/scripts/drizzle/es-de/0000_sparkling_banshee.sql b/scripts/drizzle/es-de/0000_sparkling_banshee.sql new file mode 100644 index 0000000..c86dd0e --- /dev/null +++ b/scripts/drizzle/es-de/0000_sparkling_banshee.sql @@ -0,0 +1,34 @@ +CREATE TABLE `commands` ( + `system` text, + `label` text, + `command` text NOT NULL, + FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE cascade ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `emulators` ( + `name` text PRIMARY KEY NOT NULL, + `fullname` text, + `systempath` text DEFAULT (json_array()) NOT NULL, + `staticpath` text DEFAULT (json_array()) NOT NULL, + `corepath` text DEFAULT (json_array()) NOT NULL, + `androidpackage` text DEFAULT (json_array()) NOT NULL, + `winregistrypath` text DEFAULT (json_array()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `emulators_name_unique` ON `emulators` (`name`);--> statement-breakpoint +CREATE TABLE `systemMappings` ( + `source` text, + `sourceSlug` text, + `sourceId` integer, + `system` text NOT NULL, + FOREIGN KEY (`system`) REFERENCES `systems`(`name`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `systems` ( + `name` text PRIMARY KEY NOT NULL, + `fullname` text, + `path` text, + `extension` text DEFAULT (json_array()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `systems_name_unique` ON `systems` (`name`); \ No newline at end of file diff --git a/scripts/drizzle/es-de/meta/0000_snapshot.json b/scripts/drizzle/es-de/meta/0000_snapshot.json new file mode 100644 index 0000000..5c40c18 --- /dev/null +++ b/scripts/drizzle/es-de/meta/0000_snapshot.json @@ -0,0 +1,234 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4ee710f-eaa5-4bbb-9e69-13d490c7142c", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "commands": { + "name": "commands", + "columns": { + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "commands_system_systems_name_fk": { + "name": "commands_system_systems_name_fk", + "tableFrom": "commands", + "tableTo": "systems", + "columnsFrom": [ + "system" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "emulators": { + "name": "emulators", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "systempath": { + "name": "systempath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "staticpath": { + "name": "staticpath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "corepath": { + "name": "corepath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "androidpackage": { + "name": "androidpackage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "winregistrypath": { + "name": "winregistrypath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + } + }, + "indexes": { + "emulators_name_unique": { + "name": "emulators_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "systemMappings": { + "name": "systemMappings", + "columns": { + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceSlug": { + "name": "sourceSlug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceId": { + "name": "sourceId", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "systemMappings_system_systems_name_fk": { + "name": "systemMappings_system_systems_name_fk", + "tableFrom": "systemMappings", + "tableTo": "systems", + "columnsFrom": [ + "system" + ], + "columnsTo": [ + "name" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "systems": { + "name": "systems", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "fullname": { + "name": "fullname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extension": { + "name": "extension", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + } + }, + "indexes": { + "systems_name_unique": { + "name": "systems_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/scripts/drizzle/es-de/meta/_journal.json b/scripts/drizzle/es-de/meta/_journal.json new file mode 100644 index 0000000..65dbc00 --- /dev/null +++ b/scripts/drizzle/es-de/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1776039605377, + "tag": "0000_sparkling_banshee", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/scripts/generate-es-de-mapping.ts b/scripts/generate-es-de-mapping.ts index 115c19f..8e8fa74 100644 --- a/scripts/generate-es-de-mapping.ts +++ b/scripts/generate-es-de-mapping.ts @@ -96,12 +96,18 @@ await Promise.all(platforms.map(async ([platform, arch]) => }); const rommMapping = rommPlatforms.data?.find(p => - p.slug === (customMappings as any)[name] || - p.slug === name || - p.igdb_slug === name || - p.hltb_slug === name || - p.moby_slug === name || - p.display_name === fullname + { + const custom = (customMappings as any)[name]; + if (Array.isArray(custom) && custom.some(m => m === p.slug)) + { + return true; + } + + return p.slug === custom || + p.slug === name || + p.igdb_slug === name || + p.display_name === fullname; + } ); const mappings: { diff --git a/src/bun/api/app.ts b/src/bun/api/app.ts index 350607e..e4401bb 100644 --- a/src/bun/api/app.ts +++ b/src/bun/api/app.ts @@ -18,13 +18,13 @@ import EventEmitter from "node:events"; import { appPath } from "../utils"; import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite"; import { ensureDir } from "fs-extra"; -import UpdateStoreJob from "./jobs/update-store"; import { getStoreFolder } from "./store/services/gamesService"; import { PluginManager } from "./plugins/plugin-manager"; import registerPlugins from "./plugins/register-plugins"; import controls from './controls/controls'; import { RunAPIServer } from "./rpc"; import { RunBunServer } from "../server"; +import ReloadPluginsJob from "./jobs/reload-plugins-job"; export let config: Conf; export let customEmulators: Conf>; @@ -72,7 +72,6 @@ export async function load () console.log("Config Path Located At: ", config.path); console.log("Custom Emulator Paths Located At: ", customEmulators.path); console.log("App Directory is ", process.env.APPDIR); - console.log("Store Directory is ", getStoreFolder()); cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite'); fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json')); @@ -84,14 +83,14 @@ export async function load () emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema }); await reloadDatabase(); plugins = new PluginManager(); - await registerPlugins(plugins); api = await RunAPIServer(); + await registerPlugins(plugins); + taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); controlsHandle = await controls(); if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer(); config.onDidChange('downloadPath', () => reloadDatabase()); config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v })); - taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); } export async function cleanup () @@ -120,6 +119,7 @@ export async function reloadDatabase () db = drizzle(sqlite, { schema }); cache = drizzle(cacheSqlite, { schema: cacheSchema }); migrate(db!, { migrationsFolder: appPath("./drizzle") }); + sqlite.run("PRAGMA foreign_keys = ON;"); await cache.run(` CREATE TABLE IF NOT EXISTS item_cache ( key TEXT PRIMARY KEY, diff --git a/src/bun/api/emulatorjs/emulatorjs.ts b/src/bun/api/emulatorjs/emulatorjs.ts index 247ce6a..9e81b46 100644 --- a/src/bun/api/emulatorjs/emulatorjs.ts +++ b/src/bun/api/emulatorjs/emulatorjs.ts @@ -70,13 +70,13 @@ export default new Elysia({ prefix: '/emulatorjs' }) const localGame = await getLocalGame(source, id); if (!localGame) return status("Not Found"); - const changedSaveFiles: SaveFileChange[] = []; + const changedSaveFiles: Record = {}; if (save) { const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS"); const saveFile = path.join(savesPath, save.name); await Bun.write(saveFile, save); - changedSaveFiles.push({ subPath: save.name, cwd: savesPath }); + changedSaveFiles.gameflow = { subPath: save.name, cwd: savesPath, shared: false }; events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" }); } await updateLocalLastPlayed(localGame.id); @@ -85,7 +85,7 @@ export default new Elysia({ prefix: '/emulatorjs' }) id, saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"), gameInfo: { platformSlug: localGame?.platform.slug }, - changedSaveFiles: changedSaveFiles, + changedSaveFiles: [], validChangedSaveFiles: changedSaveFiles, command: { id: "EMULATORJS", diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 8e489cf..b7abb35 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -1,26 +1,26 @@ import Elysia, { status } from "elysia"; import { config, db, emulatorsDb, plugins, taskQueue } from "../app"; -import { and, eq, getTableColumns, ilike, inArray, like, sql } from "drizzle-orm"; +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 { InstallJob } from "../jobs/install-job"; import path from "node:path"; -import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; -import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, validateGameSource } from "./services/statusService"; +import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils"; +import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService"; import { errorToResponse } from "elysia/adapter/bun/handler"; -import { getEmulatorsForSystem, getRomFilePaths, launchCommand } from "./services/launchGameService"; +import { launchCommand } from "./services/launchGameService"; import { getErrorMessage, SeededRandom } from "@/bun/utils"; import { defaultFormats, defaultPlugins } from 'jimp'; import { createJimp } from "@jimp/core"; import webp from "@jimp/wasm-webp"; import * as emulatorSchema from '@schema/emulators'; -import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService"; -import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService"; +import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService"; import { host } from "@/bun/utils/host"; import { LaunchGameJob } from "../jobs/launch-game-job"; import { cores } from "../emulatorjs/emulatorjs"; +import { findEmulatorPluginIntegration } from "../store/services/emulatorsService"; // A custom jimp that supports webp const Jimp = createJimp({ @@ -58,8 +58,15 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width, if (typeof img === 'string') { - const rommFetch = await fetch(img); - return rommFetch; + const res = await fetch(img); + + return new Response(res.body, { + status: res.status, + headers: { + "Content-Type": res.headers.get("Content-Type") ?? "image/jpeg", + "Cache-Control": "public, max-age=86400", + }, + }); } return img; @@ -135,190 +142,144 @@ export default new Elysia() .get('/games', async ({ query, set }) => { const games: FrontEndGameType[] = []; - const filterSets: FrontEndFilterSets = { - age_ratings: new Set(), - player_counts: new Set(), - languages: new Set(), - companies: new Set(), - genres: new Set() - }; - if (query.source === 'store') + const where: any[] = []; + let localGamesSet: Set | undefined; + + if (query.platform_slug) { - const shuffledGames = await getShuffledStoreGames(); - set.headers['x-max-items'] = shuffledGames.length; - const storeGames = await Promise.all(shuffledGames.filter(g => + where.push(eq(schema.platforms.slug, query.platform_slug)); + } else if (query.platform_id && query.platform_source === 'local') + { + where.push(eq(schema.platforms.id, query.platform_id)); + } + else if (query.platform_id && query.platform_source) + { + const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined }); + if (platform) { - if (query.search) - return path.basename(g.path).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); - return true; - }) - .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) - .map(async (e) => + where.push(eq(schema.platforms.slug, platform?.slug)); + } + } + + if (query.search) + { + where.push(like(schema.games.name, query.search)); + } + + if (query.source) + { + where.push(eq(schema.games.source, query.source)); + } + + const ordering: any[] = []; + + if (query.orderBy) + { + switch (query.orderBy) + { + case 'added': + ordering.push(desc(schema.games.created_at)); + break; + case 'activity': + ordering.push(sql`MAX(COALESCE(${schema.games.created_at}, '1970-01-01'), COALESCE(${schema.games.last_played}, '1970-01-01')) DESC`); + break; + case 'name': + ordering.push(desc(schema.games.name)); + break; + case "release": + ordering.push(sql`json_extract(${schema.games.metadata}, '$.first_release_date') DESC NULLS LAST`); + break; + } + } + + const localGames = await db.select({ + ...getTableColumns(schema.games), + platform: schema.platforms, + screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), + }) + .from(schema.games) + .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) + .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) + .groupBy(schema.games.id) + .orderBy(...ordering) + .where(and(...where)); + + localGamesSet = new Set( + localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) + .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) + ); + + function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) + { + if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; + if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; + if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; + return false; + } + + if (query.collection_id) + { + // Collections are just a remote thing for now. + const remoteGames: FrontEndGameTypeWithIds[] = []; + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.map(g => + { + if (localGameExistsPredicate(g)) { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); + return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + } + else + { + return g; + } + })); - const localGame = await db.select({ - ...getTableColumns(schema.games), - platform: schema.platforms, - screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), - }) - .from(schema.games) - .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) - .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) - .groupBy(schema.games.id) - .where(and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`))); - - if (localGame.length > 0) return convertLocalToFrontend(localGame[0]); - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); - })); - games.push(...storeGames.filter(g => g !== undefined)); } else { - const where: any[] = []; - let localGamesSet: Set | undefined; - - if (query.platform_slug) + games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g => { - where.push(eq(schema.platforms.slug, query.platform_slug)); - } else if (query.platform_id && query.platform_source === 'local') - { - where.push(eq(schema.platforms.id, query.platform_id)); - } - else if (query.platform_id && query.platform_source) - { - const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: String(query.platform_id) }); - if (platform) + if (query.genres && query.genres.length > 0) { - where.push(eq(schema.platforms.slug, platform?.slug)); + if (!g.metadata) return false; + if (!g.metadata.genres) return false; + if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; } - } - if (query.search) + return true; + }).map(g => { - where.push(like(schema.games.name, query.search)); - } + return convertLocalToFrontend(g); + })); - if (query.source) + if (query.localOnly !== true) { - where.push(eq(schema.games.source, query.source)); - } - - const localGames = await db.select({ - ...getTableColumns(schema.games), - platform: schema.platforms, - screenshotIds: sql`coalesce(json_group_array(${schema.screenshots.id}),json('[]'))`.mapWith(d => JSON.parse(d) as number[]), - }) - .from(schema.games) - .leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id)) - .leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id)) - .groupBy(schema.games.id) - .where(and(...where)); - - localGamesSet = new Set( - localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) - .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) - ); - - function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) - { - if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; - if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; - if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; - return false; - } - - if (query.collection_id) - { - // Collections are just a remote thing for now. const remoteGames: FrontEndGameTypeWithIds[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); - games.push(...remoteGames.map(g => + const remoteGameSet = new Set(); + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.filter(g => { if (localGameExistsPredicate(g)) { - return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + return false; } - else - { - return g; - } - })); - } else - { - games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g => - { - if (query.genres && query.genres.length > 0) + if (g.igdb_id) { - if (!g.metadata) return false; - if (!g.metadata.genres) return false; - if (query.genres.some(genre => !g.metadata?.genres?.includes(genre))) return false; + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); } return true; - }).map(g => - { - return convertLocalToFrontend(g); })); - - if (query.localOnly !== true) - { - const remoteGames: FrontEndGameTypeWithIds[] = []; - const remoteGameSet = new Set(); - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => - { - if (localGameExistsPredicate(g)) - { - return false; - } - - if (g.igdb_id) - { - const igdbId = `igdb@${g.igdb_id}`; - if (remoteGameSet.has(igdbId)) return false; - remoteGameSet.add(igdbId); - } - - if (g.ra_id) - { - const raId = `ra@${g.ra_id}`; - if (remoteGameSet.has(raId)) return false; - remoteGameSet.add(raId); - } - - return true; - })); - } else - { - await plugins.hooks.games.fetchFilters.promise({ filters: filterSets }).catch(e => console.error(e)); - } - - localGames.map(g => - { - const metadata: any = g.metadata; - if (metadata.genres && Array.isArray(metadata.genres)) - { - metadata.genres.forEach((g: string) => filterSets.genres.add(g)); - } - if (metadata.age_ratings && Array.isArray(metadata.age_ratings)) - { - metadata.age_ratings.forEach((g: string) => filterSets.age_ratings.add(g)); - } - if (metadata.companies && Array.isArray(metadata.companies)) - { - metadata.companies.forEach((g: string) => filterSets.companies.add(g)); - } - if (metadata.player_count) - { - filterSets.player_counts.add(metadata.player_count); - } - }); } } @@ -342,7 +303,37 @@ export default new Elysia() } - const filterLists: FrontEndFilterLists = { + return { games }; + }, { + query: GameListFilterSchema, + }) + .get('/games/filters', async ({ query: { source } }) => + { + const filterSets: FrontEndFilterSets = { + age_ratings: new Set(), + player_counts: new Set(), + languages: new Set(), + companies: new Set(), + genres: new Set() + }; + + let filter: any = undefined; + if (source) filter = eq(schema.games.source, source); + const local_metadata = await db.query.games.findMany({ columns: { metadata: true }, where: filter }); + + local_metadata.forEach(game => + { + game.metadata.age_ratings?.forEach(r => filterSets.age_ratings.add(r)); + game.metadata.genres?.forEach(r => filterSets.genres.add(r)); + game.metadata.companies?.forEach(r => filterSets.companies.add(r)); + + if (game.metadata.player_count) + filterSets.player_counts.add(game.metadata.player_count); + }); + + await plugins.hooks.games.fetchFilters.promise({ filters: filterSets, source }); + + const filters: FrontEndFilterLists = { age_ratings: Array.from(filterSets.age_ratings), player_counts: Array.from(filterSets.player_counts), languages: Array.from(filterSets.languages), @@ -350,34 +341,21 @@ export default new Elysia() genres: Array.from(filterSets.genres) }; - return { games, filters: filterLists }; + return filters; }, { - query: GameListFilterSchema, + query: z.object({ source: z.string().optional() }) }) .get('/rom/:source/:id', async ({ params: { id, source } }) => { - const localGame = await db.query.games.findFirst({ - where: getLocalGameMatch(id, source), - columns: { path_fs: true }, - with: { platform: { columns: { es_slug: true } } } - }); + const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id }); - if (!localGame?.path_fs) + if (!filePaths || filePaths.length <= 0) { - return status("Not Found"); + return status("Not Found", "No Valid Roms Found"); } - const downloadPath = config.get('downloadPath'); - const path_fs = path.join(downloadPath, localGame.path_fs); + return Bun.file(filePaths[0]); - const filesPaths = await getRomFilePaths(path_fs, localGame.platform.es_slug ?? undefined); - - if (filesPaths.length <= 0) - { - throw new Error("No Valid Roms Found"); - } - - return Bun.file(filesPaths[0]); }, { params: z.object({ source: z.string(), id: z.string() }) }) @@ -392,17 +370,12 @@ export default new Elysia() const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) }); if (systemMapping) { - const emulatorNames = await getEmulatorsForSystem(systemMapping.system); - const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e })))); + const emulatorNames: string[] = []; + await plugins.hooks.emulators.findEmulatorForSystem.promise({ system: systemMapping.system, emulators: emulatorNames }); - sourceData.emulators = await Promise.all(emulators.map(async ({ name, data }) => + sourceData.emulators = (await Promise.all(emulatorNames.map(async name => { - if (data) - { - const systems = await buildStoreFrontendEmulatorSystems(data); - return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true }; - } - else if (name === 'EMULATORJS') + if (name === 'EMULATORJS') { return { name: 'EMULATORJS', @@ -424,22 +397,34 @@ export default new Elysia() return system; })), gameCount: 0, - integrations: [] - } satisfies FrontEndGameTypeDetailedEmulator; - } - else - { - return { - name: name, - logo: "", - systems: [], - gameCount: 0, - validSources: [], + source: 'local', integrations: [] } satisfies FrontEndGameTypeDetailedEmulator; } - })); + const foundEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: name }); + + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: name, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(id, execPaths); + + if (foundEmulator) + { + foundEmulator.validSources = execPaths; + foundEmulator.integrations = integrations; + return foundEmulator; + } + + return { + name: name, + logo: "", + source: 'local', + systems: [], + gameCount: 0, + validSources: execPaths, + integrations: integrations + } satisfies FrontEndGameTypeDetailedEmulator; + }))).filter(e => !!e); } } @@ -466,17 +451,18 @@ export default new Elysia() }, { params: z.object({ id: z.string(), source: z.string() }), }) - .post('/game/:source/:id/install', async ({ params: { id, source } }) => + .post('/game/:source/:id/install', async ({ params: { id, source }, query: { downloadId } }) => { if (!taskQueue.findJob(InstallJob.query({ source, id }), InstallJob)) { - return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source)); + return taskQueue.enqueue(InstallJob.query({ source, id }), new InstallJob(id, source, { downloadId })); } else { return status('Not Implemented'); } }, { params: z.object({ id: z.string(), source: z.string() }), + query: z.object({ downloadId: z.string().optional() }), response: z.any() }) .delete('/game/:source/:id/install', async ({ params: { id, source } }) => @@ -501,6 +487,10 @@ export default new Elysia() { return fixSource(source, id); }) + .post('/game/:source/:id/update', async ({ params: { id, source } }) => + { + return update(source, id); + }) .post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) => { const validCommands = await getValidLaunchCommandsForGame(source, id); @@ -559,8 +549,6 @@ export default new Elysia() const emulator = await getStoreEmulatorPackage(id); if (!emulator) return status("Not Found"); const systems = await buildStoreFrontendEmulatorSystems(emulator); - const systemsIdSet = new Set(systems.map(s => s.id)); - const games: FrontEndGameType[] = []; @@ -587,28 +575,6 @@ export default new Elysia() await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames }); games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); - const gamesManifest = await getStoreGameManifest(); - const storeGames = await Promise.all(gamesManifest - .filter(g => systemsIdSet.has(path.dirname(g.path))) - .map(async (e) => - { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - - const localGame = await db.query.games.findFirst({ columns: { id: true }, where: and(eq(schema.games.source, 'store'), eq(schema.games.source_id, `${system}@${id}`)) }); - - if (localGame) - { - return undefined; - } - - const storeGame = await getStoreGameFromPath(e.path); - - return convertStoreToFrontend(system, id, storeGame); - })); - - games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); - return games; }) .get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) => @@ -619,7 +585,7 @@ export default new Elysia() const sourceCompaniesSet = new Set(sourceData.metadata.companies); const sourceGenresSet = new Set(sourceData.metadata.genres); - const esSystem = sourceData.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug)), columns: { system: true } }) : undefined; + const games: (FrontEndGameType & { metadata?: any; })[] = []; @@ -632,35 +598,7 @@ export default new Elysia() games.push(...localGames.map(g => convertLocalToFrontend(g))); - const shuffledGames = await getShuffledStoreGames(); - const storeGames = await Promise.all(shuffledGames - .filter(g => - { - const system = path.dirname(g.path); - const id = path.basename(g.path, path.extname(g.path)); - if (localGamesSourceSet.has(`store@${system}@${id}`)) - return false; - - if (esSystem) - { - if (path.dirname(g.path) === esSystem.system) return true; - } - - return false; - }) - .map(async (e) => - { - const system = path.dirname(e.path); - const id = path.basename(e.path, path.extname(e.path)); - const storeGame = await getStoreGameFromPath(e.path); - return convertStoreToFrontend(system, id, storeGame); - })); - - if (storeGames) - { - games.push(...storeGames.slice(0, 3)); - } const remoteGames: (FrontEndGameType & { metadata?: any; })[] = []; plugins.hooks.games.fetchRecommendedGamesForGame.promise({ diff --git a/src/bun/api/games/platforms.ts b/src/bun/api/games/platforms.ts index a33e155..22161ae 100644 --- a/src/bun/api/games/platforms.ts +++ b/src/bun/api/games/platforms.ts @@ -1,6 +1,6 @@ import Elysia, { status } from "elysia"; import z from "zod"; -import { and, count, eq, getTableColumns, not } from "drizzle-orm"; +import { and, count, eq, getTableColumns, not, notExists } from "drizzle-orm"; import { db, plugins } from "../app"; import * as schema from "@schema/app"; @@ -93,7 +93,8 @@ export default new Elysia() if (!remotePlatform) return status("Not Found"); return remotePlatform; } - }, { params: z.object({ source: z.string(), id: z.string() }) }).get('/platform/local/:id/cover', async ({ params: { id }, set }) => + }, { params: z.object({ source: z.string(), id: z.string() }) }) + .get('/platform/local/:id/cover', async ({ params: { id }, set }) => { set.headers["cross-origin-resource-policy"] = 'cross-origin'; @@ -112,4 +113,35 @@ export default new Elysia() set.headers["content-type"] = coverBlob.cover_type; } return status(200, coverBlob.cover); - }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }); \ No newline at end of file + }, { response: { 200: z.instanceof(Buffer), 404: z.any() }, params: z.object({ id: z.coerce.number() }) }) + .post('/platform/local/:id/update', async ({ params: { id } }) => + { + const localPlatform = await db.query.platforms.findFirst({ where: eq(schema.platforms.id, Number(id)) }); + if (!localPlatform) return status("Not Found"); + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: localPlatform.slug + }); + let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${localPlatform.slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } + + await db.update(schema.platforms).set({ + name: platformLookup?.name, + cover: Buffer.from(await platformCover.arrayBuffer()), + cover_type: platformCover.headers.get('content-type'), + }).where(eq(schema.platforms.id, localPlatform.id)); + }) + .delete('/platform/local/:id', async ({ params: { id } }) => + { + const deleted = await db.delete(schema.platforms).where(and(eq(schema.platforms.id, Number(id)), + notExists( + db + .select() + .from(schema.games) + .where(eq(schema.games.platform_id, Number(id))) + ))).returning(); + if (deleted.length <= 0) return status("Not Found"); + }); \ No newline at end of file diff --git a/src/bun/api/games/services/launchGameService.ts b/src/bun/api/games/services/launchGameService.ts index 4dde81b..1a0dddc 100644 --- a/src/bun/api/games/services/launchGameService.ts +++ b/src/bun/api/games/services/launchGameService.ts @@ -1,19 +1,12 @@ import path from 'node:path'; -import { Glob, which } from 'bun'; +import { Glob } from 'bun'; import fs from 'node:fs/promises'; -import { existsSync, readFileSync } from 'node:fs'; -import * as schema from '@schema/emulators'; -import { eq } from 'drizzle-orm'; -import { config, customEmulators, emulatorsDb, taskQueue } from '../../app'; -import os from 'node:os'; -import { cores } from '../../emulatorjs/emulatorjs'; +import { existsSync } from 'node:fs'; +import { config, taskQueue } from '../../app'; import { LaunchGameJob } from '../../jobs/launch-game-job'; import { getStoreEmulatorPackage } from '../../store/services/gamesService'; import { getOrCachedScoopPackage } from '../../store/services/emulatorsService'; -export const varRegex = /%([^%]+)%/g; -export const assignRegex = /(%\w+%)=(\S+) /g; - export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string) { if (taskQueue.hasActiveOfType(LaunchGameJob)) @@ -24,285 +17,6 @@ export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, taskQueue.enqueue(LaunchGameJob.id, new LaunchGameJob(id, validCommand, source, sourceId)); } -/** - * Get the emulators related to the given system - * @param systemSlug the ES-DE slug for the system - */ -export async function getEmulatorsForSystem (systemSlug: string) -{ - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - const emulators = new Set(); - await Promise.all(system.commands.map(async (command, index) => - { - let cmd = command.command; - - const matches = Array.from(cmd.matchAll(varRegex)); - matches.forEach(([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - emulators.add(emulatorName); - return; - } - }); - })); - - - - if (cores[systemSlug]) - { - emulators.add('EMULATORJS'); - } - - return Array.from(emulators); -} - -export async function getRomFilePaths (gamePath: string, systemSlug?: string) -{ - if (!existsSync(gamePath)) - { - throw new Error(`Provided rom path is missing: '${gamePath}'`); - } - - const gamePathStat = await fs.stat(gamePath); - const validFiles: string[] = []; - - if (gamePathStat.isDirectory()) - { - if (!systemSlug) throw new Error("Needs system to find valid file"); - - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - const extensionList = system.extension.join(','); - - for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) - { - validFiles.push(file); - } - - if (validFiles.length <= 0) - { - throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); - } - } else if (systemSlug) - { - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${systemSlug}'`); - } - - if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) - { - validFiles.push(gamePath); - } - else - { - const extensionList = system.extension.join(','); - throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); - } - } else - { - validFiles.push(gamePath); - } - - return validFiles; -} - -/** - * - * @param data Uses es-de system slug - * @returns - */ -export async function getValidLaunchCommands (data: { - systemSlug: string; - gamePath: string; -}): Promise -{ - - const system = await emulatorsDb.query.systems.findFirst({ - with: { commands: true }, - where: eq(schema.systems.name, data.systemSlug) - }); - - if (!system) - { - throw new Error(`Could not find system '${data.systemSlug}'`); - } - - if (!system.extension || system.extension.length <= 0) - { - throw new Error(`No extensions listed for system '${data.systemSlug}'`); - } - - const downloadPath = config.get('downloadPath'); - const gamePath = path.join(downloadPath, data.gamePath); - - const validFiles: string[] = await getRomFilePaths(gamePath, data.systemSlug); - - function escapeWindowsArg (arg: string): string - { - if (process.platform === 'win32') - { - return `"${arg - .replace(/(\\*)"/g, '$1$1\\"') // escape quotes - .replace(/(\\*)$/, '$1$1') // escape trailing backslashes - }"`; - } else - { - if (arg.includes(' ')) - { - return `"${arg}"`; - } else - { - return arg; - } - } - } - - const formattedCommands = await Promise.all(system.commands - .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) - .map(async (command, index) => - { - const label = command.label; - let cmd = command.command; - - let emulator: string | undefined = undefined; - let rom = validFiles[0]; - - if (cmd.includes('%ESCAPESPECIALS%')) - rom = rom.replace(/[&()^=;,]/g, ''); - - - - const staticVars: Record = { - '%ROM%': escapeWindowsArg(rom), - '%ROMRAW%': validFiles[0], - '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), - '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), - '%ROMPATH%': escapeWindowsArg(gamePath), - '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), - '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), - '%ESCAPESPECIALS%': "", - '%HIDEWINDOW%': "" - }; - - cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => - { - try - { - const resolvedInjectFile = injectFile.replace(varRegex, (a) => - { - return staticVars[a] ?? a; - }); - if (existsSync(resolvedInjectFile)) - { - const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); - return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); - } - - return ''; - } catch (error) - { - return ''; - } - }); - - const matches = Array.from(cmd.matchAll(varRegex)); - const varList = await Promise.all(matches.map(async ([value]) => - { - if (value.startsWith("%EMULATOR_")) - { - const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); - let execs = await findExecsByName(emulatorName); - let validExec = execs.find(e => e.exists); - - emulator = emulatorName; - return [ - [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], - [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], - ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined], - ['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined] - ]; - - } - - const key = value[0].substring(1, value.length - 1); - return [[value, process.env[key]] as [string, string | undefined]]; - })); - - const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; - let startDir: string | undefined = undefined; - - if ('%STARTDIR%' in vars) - { - delete vars['%STARTDIR%']; - - cmd = cmd.replace(assignRegex, (match, p1, p2) => - { - if (p1 === '%STARTDIR%') - { - startDir = varRegex.test(p2) ? staticVars[p2] : p2; - } - return ""; - }); - } - - // missing variable - const invalid = Object.entries(vars).find(c => c[1] === undefined); - - const formattedCommand = cmd.replace(varRegex, (s) => vars[s] ?? '').trim(); - - return { - id: index, - label: label ?? undefined, - command: formattedCommand, - startDir, - valid: !invalid, emulator, - emulatorSource: vars['%EMUSOURCE%'] as any, - metadata: { - romPath: validFiles[0], - emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], - emulatorDir: vars['%EMUDIRRAW%'] - } - } satisfies CommandEntry; - })); - - return formattedCommands.filter(c => !!c); -} - -export async function findExecsByName (emulatorName: string) -{ - const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(schema.emulators.name, emulatorName) }); - if (!emulator) - { - throw new Error(`Could not find emulator ${emulatorName}`); - } - return findExecs(emulatorName, emulator); -} - export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise { const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id); @@ -355,112 +69,3 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath return undefined; } -export async function findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) -{ - const execs: EmulatorSourceEntryType[] = []; - - if (customEmulators.has(id)) - { - execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); - } - - if (emulator && emulator.systempath.length > 0) - { - const storePath = await findStoreEmulatorExec(id, emulator); - if (storePath) execs.push(storePath); - } - - if (emulator && os.platform() === 'win32') - { - const regValues = emulator.winregistrypath; - if (regValues.length > 0) - { - for (const node of regValues) - { - const registryValue = await readRegistryValue(node); - if (registryValue) - { - execs.push({ binPath: registryValue, type: 'registry', exists: true }); - } - } - - } - } - - if (emulator && emulator.systempath.length > 0) - { - const systemPath = await resolveSystemPath(emulator.systempath); - if (systemPath) - { - execs.push({ binPath: systemPath, type: 'system', exists: true }); - } - } - - if (emulator && emulator.staticpath.length > 0) - { - const staticPath = await resolveStaticPath(emulator.staticpath); - if (staticPath) - { - execs.push({ binPath: staticPath, type: 'static', exists: true }); - } - } - - return execs; -} - -async function readRegistryValue (text: string) -{ - const params = text.split('|'); - const key = path.dirname(params[0]); - const value = path.basename(params[0]); - const bin = params.length > 1 ? params[1] : undefined; - - const proc = Bun.spawn({ - cmd: ["reg", "QUERY", key, "/v", value], - stdout: "pipe", - stderr: "pipe", - }); - - const output = await new Response(proc.stdout).text(); - await proc.exited; - - if (!output.includes(value)) return null; - - const lines = output.split("\n"); - for (const line of lines) - { - if (line.includes(value)) - { - const parts = line.trim().split(/\s{4,}/); - return bin ? path.join(parts[2], bin) : parts[2]; // registry value - } - } - - return null; -} - -async function resolveStaticPath (entries: string[]) -{ - for (const entry of entries) - { - const resolved = entry.replace("~", os.homedir()); - if (await fs.exists(resolved)) - { - return resolved; - } - } - return null; -} - -async function resolveSystemPath (entries: string[]) -{ - for (const entry of entries) - { - try - { - const found = which(entry); - return found; - } catch { } - } - return null; -} \ No newline at end of file diff --git a/src/bun/api/games/services/statusService.ts b/src/bun/api/games/services/statusService.ts index 53ec3de..82f69d4 100644 --- a/src/bun/api/games/services/statusService.ts +++ b/src/bun/api/games/services/statusService.ts @@ -1,21 +1,17 @@ -import { RPC_URL, } from "@shared/constants"; -import { config, db, emulatorsDb, plugins, taskQueue } from "../../app"; -import { findExecs, getValidLaunchCommands } from "./launchGameService"; -import * as emulatorSchema from '@schema/emulators'; -import { and, eq } from "drizzle-orm"; +import { config, db, plugins, taskQueue } from "../../app"; +import { eq } from "drizzle-orm"; import { getErrorMessage } from "@/bun/utils"; -import { checkFiles, getLocalGameMatch } from "./utils"; +import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils"; import fs from 'node:fs/promises'; -import { getStoreGameFromId } from "../../store/services/gamesService"; -import { cores } from "../../emulatorjs/emulatorjs"; -import { host } from "@/bun/utils/host"; import Elysia from "elysia"; 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 { RPC_URL } from "@/shared/constants"; +import { host } from "@/bun/utils/host"; -class CommandSearchError extends Error +export class CommandSearchError extends Error { constructor(status: GameStatusType, message: string) { @@ -33,7 +29,8 @@ export async function getLocalGame (source: string, id: string) source: true, source_id: true, igdb_id: true, - ra_id: true + ra_id: true, + main_glob: true }, where: getLocalGameMatch(id, source), with: { @@ -44,6 +41,59 @@ export async function getLocalGame (source: string, id: string) return localGame; } +export async function update (source: string, id: string) +{ + const localGame = await getLocalGame(source, id); + if (!localGame) throw new Error("Could not find Local Game"); + if (!localGame.source || !localGame.source_id) throw new Error("Game has not source defined"); + const sourceGame = await getSourceGameDetailed(localGame.source, localGame.source_id, { sourceOnly: true }); + if (!sourceGame) throw new Error("Could not find source game"); + + await db.transaction(async (tx) => + { + await tx.delete(appSchema.screenshots).where(eq(appSchema.screenshots.game_id, localGame.id)); + + const paths_screenshots: string[] = [...sourceGame.paths_screenshots.map(s => `${RPC_URL(host)}${s}`)]; + if (paths_screenshots.length <= 0 && sourceGame.igdb_id) + { + const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(sourceGame.igdb_id) }); + if (igdbLookup) + { + paths_screenshots.push(...igdbLookup.screenshotUrls); + } + } + + // pre-fetch screenshots + const screenshots = await Promise.all(paths_screenshots.map(s => fetch(s))); + + if (screenshots.length > 0) + { + await tx.insert(appSchema.screenshots).values(await Promise.all(screenshots.map(async (response) => + { + const screenshot: typeof appSchema.screenshots.$inferInsert = { + game_id: localGame.id, + content: Buffer.from(await response.arrayBuffer()), + type: response.headers.get('content-type') + }; + + return screenshot; + }))); + } + + await tx.update(appSchema.games).set({ + metadata: { + age_ratings: sourceGame.metadata.age_ratings, + genres: sourceGame.metadata.genres, + player_count: sourceGame.metadata.player_count ?? undefined, + companies: sourceGame.metadata.companies, + game_modes: sourceGame.metadata.game_modes, + average_rating: sourceGame.metadata.average_rating ?? undefined, + first_release_date: sourceGame.metadata.first_release_date?.getTime() ?? undefined, + } + }).where(eq(appSchema.games.id, localGame.id)); + }); +} + export async function fixSource (source: string, id: string) { const valid = await validateGameSource(source, id); @@ -94,12 +144,10 @@ export async function validateGameSource (source: string, id: string): Promise<{ if (!localGame) return { valid: true }; if (localGame.source && localGame.source_id) { - // Store should be immutable - if (localGame.source === 'store') return { valid: true, localGame }; - const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id }); if (!sourceGame) return { valid: false, reason: "Source Missing", localGame }; - if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) + // Store should be immutable + if (localGame.source !== 'store' && sourceGame.igdb_id !== (localGame.igdb_id ?? undefined) && sourceGame.ra_id !== (localGame.ra_id ?? undefined)) { return { valid: false, reason: "Metadata Missmatch", localGame }; } @@ -115,79 +163,34 @@ export async function updateLocalLastPlayed (id: number) export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined> { - if (source === 'emulator') - { - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id) }); - const allExecs = await findExecs(id, esEmulator); - return { - commands: allExecs.map(exec => ({ - command: exec.binPath, - id: exec.type, - emulator: id, - emulatorSource: exec.type, - metadata: { - emulatorBin: exec.binPath, - emulatorDir: exec.rootPath - }, - valid: true - } satisfies CommandEntry)), - gameId: { source: "emulator", id: id } - }; - } const localGame = await getLocalGame(source, id); if (localGame) { - const rommPlatform = localGame.platform.slug; - const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) }); + const commands = await plugins.hooks.games.buildLaunchCommands.promise({ + source: localGame.source, + sourceId: localGame.source_id, + id: { source: 'local', id: String(localGame.id) }, + systemSlug: localGame.platform.slug, + gamePath: localGame.path_fs, + mainGlob: localGame.main_glob, + }); - if (esPlatform) + if (commands instanceof Error || !commands) return commands; + + const validCommand = commands.find(c => c.valid); + if (validCommand) { - if (localGame.path_fs) - { - try - { - const commands = await getValidLaunchCommands({ systemSlug: esPlatform.system, gamePath: localGame.path_fs }); - - if (cores[esPlatform.system]) - { - const gameUrl = `${RPC_URL(host)}/api/romm/rom/${source}/${id}`; - commands.push({ - id: 'EMULATORJS', - label: "Emulator JS", - command: `core=${cores[esPlatform.system]}&gameUrl=${encodeURIComponent(gameUrl)}`, - valid: true, - emulator: 'EMULATORJS', - metadata: { - romPath: gameUrl - } - }); - } - - const validCommand = commands.find(c => c.valid); - if (validCommand) - { - return { commands: commands.filter(c => c.valid), gameId: { id: String(localGame.id), source: 'local' }, source: localGame.source ?? source, sourceId: 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(', ')}`); - } - } catch (error) - { - console.error(error); - return new CommandSearchError('error', getErrorMessage(error)); - } - - } else - { - return new CommandSearchError('error', 'Missing Path'); - } + return { + commands: commands.filter(c => c.valid), + gameId: { id: String(localGame.id), source: 'local' }, + source: localGame.source ?? source, + sourceId: String(localGame.source_id) ?? id, + }; } else { - return new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`); + 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(', ')}`); } - } return undefined; @@ -239,6 +242,7 @@ export default function buildStatusResponse () } else { + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(ws.data.params.id, ws.data.params.source) }); const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id); if (validCommand) { @@ -255,9 +259,9 @@ export default function buildStatusResponse () }); } - } else if (ws.data.params.source === 'store') + } else if (!localGame && ws.data.params.source === 'store') { - const storeGame = await getStoreGameFromId(ws.data.params.id); + /*const storeGame = await getStoreGame(ws.data.params.id); const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); const size = Number(fileResponse.headers.get('content-length')); const stats = await fs.statfs(config.get('downloadPath')); @@ -268,8 +272,10 @@ export default function buildStatusResponse () } else { ws.send({ status: 'install', details: 'Install' }); - } - } else + }*/ + + ws.send({ status: 'install', details: 'Install' }); + } else if (!localGame) { const files = await plugins.hooks.games.fetchDownloads.promise({ source: ws.data.params.source, @@ -302,8 +308,9 @@ export default function buildStatusResponse () ws.send({ status: 'install', details: 'Install' }); } } - - + } else + { + ws.send({ status: 'error', error: "No Way To Launch" }); } } } diff --git a/src/bun/api/games/services/utils.ts b/src/bun/api/games/services/utils.ts index 955d1e7..b1b6fc2 100644 --- a/src/bun/api/games/services/utils.ts +++ b/src/bun/api/games/services/utils.ts @@ -4,10 +4,10 @@ import path from "node:path"; import { config, db, emulatorsDb, plugins } from "../../app"; import { and, eq } from "drizzle-orm"; import * as schema from "@schema/app"; -import { StoreGameType } from "@shared/constants"; -import * as emulatorSchema from "@schema/emulators"; -import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService"; +import { RPC_URL, StoreGameType } from "@shared/constants"; import { hashFile } from "@/bun/utils"; +import { host } from "@/bun/utils/host"; +import secrets from "../../secrets"; export async function calculateSize (installPath: string | null) { @@ -21,6 +21,11 @@ export async function checkInstalled (installPath: string | null) return fs.exists(path.join(config.get('downloadPath'), installPath)); } +export function getScreenshotLocalGameMatch (id: string, source: string) +{ + return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id)); +} + export function getLocalGameMatch (id: string, source: string) { return source !== 'local' ? and(eq(schema.games.source_id, id), eq(schema.games.source, source)) : eq(schema.games.id, Number(id)); @@ -35,7 +40,7 @@ export function convertLocalToFrontend (g: typeof schema.games.$inferSelect & { platform_display_name: g.platform?.name ?? null, id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, + path_covers: [`/api/romm/game/local/${g.id}/cover`], source_id: g.source_id, source: g.source, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, @@ -67,7 +72,7 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in platform_display_name: g.platform?.name ?? "Local", id: { id: String(g.id), source: 'local' }, updated_at: g.created_at, - path_cover: `/api/romm/game/local/${g.id}/cover`, + path_covers: [`/api/romm/game/local/${g.id}/cover`], source_id: g.source_id, source: g.source, path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`, @@ -82,6 +87,11 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in fs_size_bytes: fileSize, missing: !exists, local: true, + ra_id: g.ra_id, + version: g.version, + version_source: g.version_source, + version_system: g.version_system, + igdb_id: g.igdb_id, metadata: { genres: g.metadata.genres ?? [], companies: g.metadata.companies ?? [], @@ -96,74 +106,6 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in return game; } -export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise -{ - const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ - where: and(eq(emulatorSchema.systemMappings.system, system), eq(emulatorSchema.systemMappings.source, 'romm')) - }); - - const platformDef = await emulatorsDb.query.systems.findFirst({ - where: eq(emulatorSchema.systems.name, system), - columns: { fullname: true } - }); - - const gameId = `${system}@${id}`; - - const game: FrontEndGameType = { - platform_display_name: platformDef?.fullname ?? system, - path_platform_cover: `/api/romm/image/romm/assets/platforms/${rommSystem?.sourceSlug ?? system}.svg`, - id: { source: 'store', id: gameId }, - source: null, - source_id: null, - path_fs: null, - path_cover: `/api/romm/image?url=${encodeURIComponent(storeGame.pictures.titlescreens?.[0])}`, - last_played: null, - updated_at: new Date(), - slug: null, - name: storeGame.title, - platform_id: null, - platform_slug: rommSystem?.sourceSlug ?? system, - paths_screenshots: storeGame.pictures.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], - metadata: { - first_release_date: null - } - }; - - return game; -} - -export async function convertStoreToFrontendDetailed (system: string, id: string, storeGame: StoreGameType): Promise -{ - let size: number | null = null; - try - { - const fileResponse = await fetch(storeGame.file, { method: 'HEAD' }); - size = Number(fileResponse.headers.get('content-length')); - } catch (error) - { - console.error(error); - } - - const detailed: FrontEndGameTypeDetailed = { - ...await convertStoreToFrontend(system, id, storeGame), - summary: storeGame.description, - fs_size_bytes: size, - missing: false, - local: false, - metadata: { - genres: storeGame.tags, - companies: [], - game_modes: [], - age_ratings: [], - player_count: "", - average_rating: null, - first_release_date: null - } - }; - - return detailed; -} - export async function getLocalGameDetailed (match: any) { const localGame = await db.query.games.findFirst({ @@ -182,7 +124,7 @@ export async function getLocalGameDetailed (match: any) return undefined; } -export async function getSourceGameDetailed (source: string, id: string) +export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; }) { if (source === 'local') { @@ -194,30 +136,13 @@ export async function getSourceGameDetailed (source: string, id: string) { const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source)); - if (source === 'store') + const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); + if (localGame && options?.sourceOnly !== true) { - const gameId = extractStoreGameSourceId(id); - const storeGame = await getStoreGame(gameId.system, gameId.id); - if (!storeGame) return undefined; - const storeFrontendGame = await convertStoreToFrontendDetailed(gameId.system, gameId.id, storeGame); - if (localGame) - { - return { ...storeFrontendGame, ...localGame }; - } - return storeFrontendGame; - } else - { - const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame }); - if (remoteGame) - { - return remoteGame; - } else if (localGame) - { - return localGame; - } + return localGame; } - return undefined; + return remoteGame; } } diff --git a/src/bun/api/hooks/app.ts b/src/bun/api/hooks/app.ts index bf592a0..a1f0eec 100644 --- a/src/bun/api/hooks/app.ts +++ b/src/bun/api/hooks/app.ts @@ -1,10 +1,12 @@ import { AuthHooks } from "./auth"; import { EmulatorHooks } from "./emulators"; import { GameHooks } from "./games"; +import { StoreHooks } from "./store"; export class GameflowHooks { games = new GameHooks(); emulators = new EmulatorHooks(); auth = new AuthHooks(); + store = new StoreHooks(); } \ No newline at end of file diff --git a/src/bun/api/hooks/emulators.ts b/src/bun/api/hooks/emulators.ts index 6740b30..4ac51e7 100644 --- a/src/bun/api/hooks/emulators.ts +++ b/src/bun/api/hooks/emulators.ts @@ -22,6 +22,8 @@ export class EmulatorHooks * Triggered when emulator is downloaded or updated */ emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']); + findEmulatorSource = new AsyncSeriesHook<[ctx: { emulator: string; sources: EmulatorSourceEntryType[]; }]>(['ctx']); + findEmulatorForSystem = new AsyncSeriesHook<[ctx: { system: string; emulators: string[]; }]>(['ctx']); constructor() { diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index f1f4a6a..f4ae463 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -3,6 +3,14 @@ import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfal export class GameHooks { + buildLaunchCommands = new AsyncSeriesBailHook<[ctx: { + source: string | null; + sourceId: string | null; + id: FrontEndId; + systemSlug: string; + gamePath: string | null, + mainGlob?: string | null, + }], CommandEntry[] | Error | undefined>(['ctx']); /** override the launch command for an emulator * @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing * @param ctx.emulator The emulator ID if any @@ -20,7 +28,7 @@ export class GameHooks id: FrontEndId; platformSlug?: string; }; - }], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']); + }], { args: string[], savesPath?: SaveSlots; env?: Record; } | undefined, { emulator: string; }>(['ctx']); /** * Is the given emulator for the given command supported * @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects. @@ -37,9 +45,9 @@ export class GameHooks fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; games: FrontEndGameTypeWithIds[]; - filters: FrontEndFilterSets; }]>(['ctx']); fetchFilters = new AsyncSeriesHook<[ctx: { + source?: string; filters: FrontEndFilterSets; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { @@ -58,7 +66,12 @@ export class GameHooks fetchDownloads = new AsyncSeriesBailHook<[ctx: { source: string; id: string; + downloadId?: string; }], DownloadInfo | undefined>(['ctx']); + fetchRomFiles = new AsyncSeriesBailHook<[ctx: { + source: string; + id: string; + }], string[] | undefined>(['ctx']); fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: { game: FrontEndGameTypeDetailed, games: (FrontEndGameType & { metadata?: any; })[]; @@ -73,28 +86,39 @@ export class GameHooks id: string; }], FrontEndPlatformType | undefined>(['ctx']); platformLookup = new AsyncSeriesBailHook<[ctx: { - source: string; - id: string; - }], { slug: string; } | undefined>(['ctx']); + source?: string; + id?: string; + slug?: string; + }], { + slug: string; + url_logo?: string | null; + name?: string; + family_name?: string; + } | undefined>(['ctx']); + gameLookup = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], { screenshotUrls: string[]; } | undefined>(['ctx']); fetchPlatforms = new AsyncSeriesHook<[ctx: { platforms: FrontEndPlatformType[]; }]>(['ctx']); prePlay = new AsyncSeriesHook<[ctx: { source: string, id: string; - saveFolderPath?: string; + saveFolderSlots: Record; setProgress: (progress: number, state: string) => void, command: CommandEntry; gameInfo: { platformSlug?: string; }; }]>(["ctx"]); + /** + * @param changedSaveFiles Auto detected changed files. This is mainly used to see what changed during gameplay + * @param validChangedSaveFiles This will be final valid changes to be saved using save integrations like rclone + */ postPlay = new AsyncSeriesHook<[ctx: { source: string, id: string; - saveFolderPath?: string; - changedSaveFiles: SaveFileChange[], - validChangedSaveFiles: SaveFileChange[], + saveFolderSlots?: Record; + changedSaveFiles: { subPath: string, cwd: string; }[], + validChangedSaveFiles: Record, command: CommandEntry; gameInfo: { platformSlug?: string; diff --git a/src/bun/api/hooks/store.ts b/src/bun/api/hooks/store.ts new file mode 100644 index 0000000..de889d1 --- /dev/null +++ b/src/bun/api/hooks/store.ts @@ -0,0 +1,10 @@ +import { EmulatorDownloadInfoType } from "@/shared/constants"; +import { AsyncSeriesBailHook, AsyncSeriesHook } from "tapable"; + +export class StoreHooks +{ + fetchFeaturedGames = new AsyncSeriesHook<[ctx: { games: FrontEndGameTypeDetailed[]; }]>(['ctx']); + fetchEmulators = new AsyncSeriesHook<[ctx: { emulators: FrontEndEmulator[]; search?: string; }]>(['ctx']); + fetchEmulator = new AsyncSeriesBailHook<[ctx: { id: string; }], FrontEndEmulatorDetailed | undefined>(['ctx']); + fetchDownload = new AsyncSeriesBailHook<[ctx: { id: string; }], (EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined>(['ctx']); +} \ No newline at end of file diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 0564111..07f4fb6 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -5,7 +5,6 @@ import * as schema from "@schema/app"; import * as emulatorSchema from "@schema/emulators"; import path, { join } from 'node:path'; import { config, db, emulatorsDb, events, plugins } from "../app"; -import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService"; import * as igdb from 'ts-igdb-client'; import secrets from "../secrets"; import { simulateProgress } from "@/bun/utils"; @@ -13,17 +12,16 @@ import { Downloader } from "@/bun/utils/downloader"; import Seven from 'node-7z'; import z from "zod"; import { checkFiles } from "../games/services/utils"; -import { ensureDir, existsSync } from "fs-extra"; +import { ensureDir, move } from "fs-extra"; import { path7za } from "7zip-bin"; -import slugify from 'slugify'; import StreamZip from 'node-stream-zip'; -import { createExtractorFromFile } from 'node-unrar-js'; import { which } from "bun"; interface JobConfig { dryRun?: boolean; dryDownload?: boolean; + downloadId?: string; } export type InstallJobStates = 'download' | 'extract'; @@ -55,34 +53,7 @@ export class InstallJob implements IJob const downloadPath = config.get('downloadPath'); let info: DownloadInfo | undefined; - switch (this.source) - { - case 'store': - const game = await getStoreGameFromId(this.gameId); - const gameId = extractStoreGameSourceId(this.gameId); - info = { - coverUrl: game.pictures.titlescreens[0], - screenshotUrls: game.pictures.screenshots, - files: [{ - url: new URL(game.file), - file_path: `roms/${game.system}`, - file_name: path.basename(decodeURI(game.file)), - size: 0 - }], - slug: this.gameId, - source_id: this.gameId, - name: game.title, - summary: game.description, - system_slug: gameId.system, - path_fs: path.join('roms', gameId.system, slugify(game.title)), - extract_path: '.', - }; - - break; - default: - info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId }); - break; - } + info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId }); if (!info) throw new Error(`Could not find downloader for source ${this.source}`); @@ -116,9 +87,10 @@ export class InstallJob implements IJob { let progress = 0; const progressDelta = 1 / downloadedFiles.length; + const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); + for (const filePath of downloadedFiles) { - const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); await new Promise(async (resolve, reject) => { let sevenZipPath = process.env.ZIP7_PATH ?? path7za; @@ -176,8 +148,23 @@ export class InstallJob implements IJob throw e; } }); + progress += progressDelta * 100; } + + // check if 1 root folder we need to get rid of + const contents = await fs.readdir(extractPath); + if (contents.length === 1) + { + const stat = await fs.stat(path.join(extractPath, contents[0])); + if (stat.isDirectory()) + { + console.log("Found 1 root folder, using that instead"); + const tmpGameFolder = `${extractPath} (1)`; + await move(path.join(extractPath, contents[0]), tmpGameFolder, { overwrite: true }); + await move(tmpGameFolder, extractPath, { overwrite: true }); + } + } } } @@ -221,7 +208,15 @@ export class InstallJob implements IJob if (!existingPlatform) { // TODO: use something else than the romm demo as CDN - const platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.system_slug}.svg`); + + const platformLookup = await plugins.hooks.games.platformLookup.promise({ + slug: info.platform?.slug ?? info.system_slug + }); + let platformCover = await fetch(`https://demo.romm.app/assets/platforms/${info.platform?.slug ?? info.system_slug}.svg`); + if (!platformCover.ok && platformLookup?.url_logo) + { + platformCover = await fetch(platformLookup.url_logo); + } if (!esPlatform && !info.platform) { @@ -251,7 +246,7 @@ export class InstallJob implements IJob cover_type: platformCover.headers.get('content-type'), name: info.platform?.name ?? esPlatform?.system.fullname ?? '', family_name: info.platform?.family_name, - es_slug: esPlatform?.system.name ?? undefined + es_slug: esPlatform?.system.name ?? undefined, }; // TODO: add ES slug once I have better way to query ES @@ -278,22 +273,20 @@ export class InstallJob implements IJob name: info.name, cover, cover_type: coverResponse.headers.get('content-type'), - metadata: info.metadata + metadata: info.metadata, + main_glob: info.main_glob, + version: info.version, + version_source: info.version_source, + version_system: info.version_system }; const [{ id }] = await tx.insert(schema.games).values(game).returning({ id: schema.games.id }); - if (info.screenshotUrls.length <= 0 && process.env.TWITCH_CLIENT_ID) + if (info.screenshotUrls.length <= 0 && info.igdb_id) { - const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); - if (access_token) - { - const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); - - const { data } = await client.request('artworks').pipe(igdb.fields(['game', 'url']), igdb.where('game', '=', info.igdb_id)).execute(); - - info.screenshotUrls.push(...data.filter(s => s.url).map(s => s.url!)); - } + const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) }); + if (igdbLookup) return igdbLookup.screenshotUrls; + return []; } // pre-fetch screenshots diff --git a/src/bun/api/jobs/jobs.ts b/src/bun/api/jobs/jobs.ts index 7bc92c7..328c04e 100644 --- a/src/bun/api/jobs/jobs.ts +++ b/src/bun/api/jobs/jobs.ts @@ -10,6 +10,7 @@ import { IJob } from "../task-queue"; import { LaunchGameJob } from "./launch-game-job"; import { BiosDownloadJob } from "./bios-download-job"; import { InstallJob } from "./install-job"; +import ReloadPluginsJob from "./reload-plugins-job"; function registerJob< const Path extends string, @@ -107,4 +108,5 @@ export const jobs = new Elysia({ prefix: '/api/jobs' }) .use(registerJob(UpdateStoreJob)) .use(registerJob(BiosDownloadJob)) .use(registerJob(InstallJob)) + .use(registerJob(ReloadPluginsJob)) .use(registerJob(EmulatorDownloadJob)); diff --git a/src/bun/api/jobs/launch-game-job.ts b/src/bun/api/jobs/launch-game-job.ts index bcea594..9159002 100644 --- a/src/bun/api/jobs/launch-game-job.ts +++ b/src/bun/api/jobs/launch-game-job.ts @@ -19,8 +19,8 @@ export class LaunchGameJob implements IJob; - saveFolderPath?: string; + changedSaveFiles: Map; + saveSlots: SaveSlots = {}; constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string) { @@ -47,9 +47,8 @@ export class LaunchGameJob implements IJob console.error(e)); } @@ -59,7 +58,7 @@ export class LaunchGameJob implements IJob { console.error(e); - reject(e); + resolve(1); }); game = spawnGame; } else if (this.validCommand.metadata.emulatorBin) { - this.saveFolderPath = commandArgs.savesPath; + this.saveSlots = commandArgs.savesPath ?? {}; await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }); @@ -154,12 +155,15 @@ export class LaunchGameJob implements IJob { diff --git a/src/bun/api/jobs/reload-plugins-job.ts b/src/bun/api/jobs/reload-plugins-job.ts new file mode 100644 index 0000000..4796fc8 --- /dev/null +++ b/src/bun/api/jobs/reload-plugins-job.ts @@ -0,0 +1,15 @@ +import z from "zod"; +import { IJob, JobContext } from "../task-queue"; +import { plugins } from "../app"; + +export default class ReloadPluginsJob implements IJob +{ + static id = "reload-plugins-job" as const; + static dataSchema = z.never(); + group = "reload-plugins"; + + async start (context: JobContext, never, string>) + { + await plugins.reloadAll(context); + } +} \ No newline at end of file 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 cc3cfc7..7a0eadc 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 { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginContextType, PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import path from 'node:path'; import { config } from "@/bun/api/app"; @@ -7,7 +7,7 @@ export default class CEMUIntegration implements PluginType { emulator = 'CEMU'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`); } - return { args, savesPath: savesPath }; + return { args, savesPath: { cemu: { cwd: savesPath } } }; }); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json index bbabba6..9a0c5c6 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json @@ -5,6 +5,7 @@ "description": "CEMU Emulator Integration", "main": "./cemu.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png", + "category": "emulators", "keywords": [ "integration", "emulator", 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 aa993a3..de853f7 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 { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import path from 'node:path'; import desc from './package.json'; import { ensureDir } from "fs-extra"; @@ -10,7 +10,7 @@ export default class DOLPHINIntegration implements PluginType { emulator = 'DOLPHIN'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -70,14 +70,18 @@ export default class DOLPHINIntegration implements PluginType finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder; } - return { args, savesPath: finalSavesPath }; + return { args, savesPath: { dolphin: { cwd: finalSavesPath } } }; }); - ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) => + ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderSlots, command, gameInfo }) => { - if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) + if (command.emulator === this.emulator && saveFolderSlots && command.metadata.romPath) { - validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir)); + validChangedSaveFiles.dolphin = { + cwd: saveFolderSlots.dolphin.cwd, + subPath: await getSavePaths(command.metadata.romPath, saveFolderSlots.dolphin.cwd, command.metadata.emulatorDir), + shared: false + }; } }); } diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json index 146b910..e413d06 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json @@ -5,6 +5,7 @@ "description": "DOLPHIN Emulator Integration", "main": "./dolphin.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg", + "category": "emulators", "keywords": [ "integration", "emulator", diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts index 2514790..8794e80 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/utils.ts @@ -128,10 +128,10 @@ async function getGCSavePaths (romPath: string, savesPath: string, location: Dol const cardPath = join(savesPath, "GC", region); const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`); - const saves: SaveFileChange[] = []; + const saves: string[] = []; for await (const file of glob.scan(cardPath)) { - saves.push({ subPath: path.join("GC", region, file), cwd: savesPath, shared: false }); + saves.push(path.join("GC", region, file)); } return saves; @@ -145,7 +145,7 @@ export async function getType (romPath: string, bundledEmulatorDir?: string): Pr return isGameCube ? "gamecube" : "wii"; } -export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise +export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise { const location = await findDolphinTool(bundledEmulatorDir); const gameId = await readGameId(romPath, location); @@ -159,6 +159,6 @@ export async function getSavePaths (romPath: string, savesPath: string, bundledE const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase(); const rootFolder = join(savesPath, "Wii", "title", "00010000", folder); const files = await fs.readdir(rootFolder, { recursive: true }); - return files.map(f => ({ subPath: path.join("Wii", "title", "00010000", f), cwd: savesPath, shared: false })); + return files.map(f => path.join("Wii", "title", "00010000", f)); } } \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json index bab4f08..6b8c725 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json @@ -5,6 +5,7 @@ "description": "PCSX2 Emulator Integration", "main": "./pcsx2.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png", + "category": "emulators", "keywords": [ "integration", "emulator", 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 db405a2..605c1b6 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,6 +1,6 @@ import { config } from "@/bun/api/app"; -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import defaultConfig from './PCSX2.ini' with { type: 'file' }; import path from 'node:path'; import { ensureDir } from "fs-extra"; @@ -11,7 +11,7 @@ export default class PCSX2Integration implements PluginType { emulator = "PCSX2"; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { @@ -103,7 +103,7 @@ export default class PCSX2Integration implements PluginType await Bun.write(configPath, ini.stringify(configFile)); - return { args, savesPath: paths.MEMORY_CARDS_PATH }; + return { args, savesPath: { pcsx2: { cwd: paths.MEMORY_CARDS_PATH } } }; } return { args }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json index f8e00f5..3801e34 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json @@ -5,6 +5,7 @@ "description": "PPSSPP Emulator Integration", "main": "./ppsspp.ts", "icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png", + "category": "emulators", "keywords": [ "integration", "emulator", 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 b6ff93d..5f5dbbb 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 { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { config } from "@/bun/api/app"; import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' }; @@ -15,7 +15,7 @@ export default class PPSSPPIntegration implements PluginType { emulator = "PPSSPP"; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => { @@ -114,7 +114,7 @@ export default class PPSSPPIntegration implements PluginType await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {})); } - return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") }; + return { args, savesPath: { ppsspp: { cwd: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") } } }; } return { args }; diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json index 3a77c30..937ebc3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json @@ -5,6 +5,7 @@ "description": "XEMU Emulator Integration", "main": "./xemu.ts", "icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg", + "category": "emulators", "keywords": [ "integration", "emulator", 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 010430c..fe0f65e 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 { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { config } from "@/bun/api/app"; import path from "node:path"; @@ -10,7 +10,7 @@ export default class XEMUIntegration implements PluginType { emulator = 'XEMU'; - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json index a6b3d25..280f14f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json @@ -5,6 +5,7 @@ "description": "XENIA Emulator Integration", "main": "./xenia.ts", "icon": "https://xenia.jp/images/logo-256x256.png", + "category": "emulators", "keywords": [ "integration", "emulator", 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 6a021da..774b918 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,4 +1,4 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; import { GameflowHooks } from "@/bun/api/hooks/app"; import { config } from "@/bun/api/app"; @@ -68,9 +68,10 @@ export default class XENIAIntegration implements PluginType if (ctx.autoValidCommand.metadata.romPath) { finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath); + return { args, savesPath: { xenia: { cwd: finalSavesPath } } }; } - return { args, savesPath: finalSavesPath }; + return { args }; }; return { args }; @@ -82,7 +83,7 @@ export default class XENIAIntegration implements PluginType return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] }; } - load (ctx: PluginContextType) + async load (ctx: PluginLoadingContextType) { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport); ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport); @@ -95,7 +96,7 @@ export default class XENIAIntegration implements PluginType if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath) { const files = await fs.readdir(saveFolderPath, { recursive: true }); - validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange))); + validChangedSaveFiles.gameflow = { cwd: saveFolderPath, subPath: files, shared: false }; } }); } 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 new file mode 100644 index 0000000..85b8aa0 --- /dev/null +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/es-de.ts @@ -0,0 +1,520 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { config, customEmulators, db, emulatorsDb } from "@/bun/api/app"; +import * as emulatorSchema from '@schema/emulators'; +import { and, eq } from "drizzle-orm"; +import { cores } from "@/bun/api/emulatorjs/emulatorjs"; +import { RPC_URL } from "@/shared/constants"; +import { host } from "@/bun/utils/host"; +import path from 'node:path'; +import { existsSync, readFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import { findStoreEmulatorExec } from "@/bun/api/games/services/launchGameService"; +import { which } from "bun"; +import os from 'node:os'; +import { getLocalGameMatch } from "@/bun/api/games/services/utils"; + +export default class IgdbIntegration implements PluginType +{ + varRegex = /%([^%]+)%/g; + assignRegex = /(%\w+%)=(\S+) /g; + + /** + * Get the emulators related to the given system + * @param systemSlug the ES-DE slug for the system + */ + async getEmulatorsForSystem (systemSlug: string) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${systemSlug}'`); + } + + const emulators = new Set(); + await Promise.all(system.commands.map(async (command, index) => + { + let cmd = command.command; + + const matches = Array.from(cmd.matchAll(this.varRegex)); + matches.forEach(([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + emulators.add(emulatorName); + return; + } + }); + })); + + if (cores[systemSlug]) + { + emulators.add('EMULATORJS'); + } + + return Array.from(emulators); + } + + async findExecs (id: string, emulator?: { winregistrypath: string[], systempath: string[], staticpath: string[]; }) + { + const execs: EmulatorSourceEntryType[] = []; + + if (customEmulators.has(id)) + { + execs.push({ binPath: customEmulators.get(id), type: 'custom', exists: await fs.exists(customEmulators.get(id)) }); + } + + if (emulator && emulator.systempath.length > 0) + { + const storePath = await findStoreEmulatorExec(id, emulator); + if (storePath) execs.push(storePath); + } + + if (emulator && process.platform === 'win32') + { + const regValues = emulator.winregistrypath; + if (regValues.length > 0) + { + for (const node of regValues) + { + const registryValue = await this.readRegistryValue(node); + if (registryValue) + { + execs.push({ binPath: registryValue, type: 'registry', exists: true }); + } + } + + } + } + + if (emulator && emulator.systempath.length > 0) + { + const systemPath = await this.resolveSystemPath(emulator.systempath); + if (systemPath) + { + execs.push({ binPath: systemPath, type: 'system', exists: true }); + } + } + + if (emulator && emulator.staticpath.length > 0) + { + const staticPath = await this.resolveStaticPath(emulator.staticpath); + if (staticPath) + { + execs.push({ binPath: staticPath, type: 'static', exists: true }); + } + } + + return execs; + } + + async readRegistryValue (text: string) + { + const params = text.split('|'); + const key = path.dirname(params[0]); + const value = path.basename(params[0]); + const bin = params.length > 1 ? params[1] : undefined; + + const proc = Bun.spawn({ + cmd: ["reg", "QUERY", key, "/v", value], + stdout: "pipe", + stderr: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (!output.includes(value)) return null; + + const lines = output.split("\n"); + for (const line of lines) + { + if (line.includes(value)) + { + const parts = line.trim().split(/\s{4,}/); + return bin ? path.join(parts[2], bin) : parts[2]; // registry value + } + } + + return null; + } + + async resolveStaticPath (entries: string[]) + { + for (const entry of entries) + { + const resolved = entry.replace("~", os.homedir()); + if (await fs.exists(resolved)) + { + return resolved; + } + } + return null; + } + + async resolveSystemPath (entries: string[]) + { + for (const entry of entries) + { + try + { + const found = which(entry); + return found; + } catch { } + } + return null; + } + + async findExecsByName (emulatorName: string) + { + const emulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulatorName) }); + if (!emulator) + { + throw new Error(`Could not find emulator ${emulatorName}`); + } + return this.findExecs(emulatorName, emulator); + } + + async getRomFilePaths (gamePath: string, config: { systemSlug?: string; mainGlob?: string | null; }) + { + if (!existsSync(gamePath)) + { + throw new Error(`Provided rom path is missing: '${gamePath}'`); + } + + const gamePathStat = await fs.stat(gamePath); + const validFiles: string[] = []; + + if (gamePathStat.isDirectory()) + { + if (config.mainGlob) + { + const files = await Array.fromAsync(fs.glob(config.mainGlob, { cwd: gamePath })); + if (files.length > 1) + { + throw new Error("Found multiple rom files"); + } else if (files.length === 0) + { + throw new Error("Found no valid roms"); + } + + validFiles.push(path.join(gamePath, files[0])); + } else + { + if (!config.systemSlug) throw new Error("Needs system to find valid file"); + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, config.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${config.systemSlug}'`); + } + + const extensionList = system.extension.join(','); + + for await (const file of fs.glob(path.join(gamePath, `/**/*.{${extensionList}}`))) + { + validFiles.push(file); + } + + if (validFiles.length <= 0) + { + throw new Error(`Could not find valid rom file. Must be a file that ends in '${extensionList}'`); + } + } + } else if (config.systemSlug) + { + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, config.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${config.systemSlug}'`); + } + + if (system.extension.some(e => gamePath.toLocaleLowerCase().endsWith(e.toLocaleLowerCase()))) + { + validFiles.push(gamePath); + } + else + { + const extensionList = system.extension.join(','); + throw new Error(`Invalid Rom File. Must be a file that ends in '${extensionList}'`); + } + } else + { + validFiles.push(gamePath); + } + + return validFiles; + } + + /** + * + * @param data Uses es-de system slug + * @param mainGlob The main file glob supported pattern to search for if game path is a directory + * @returns + */ + async getValidLaunchCommands (data: { + systemSlug: string; + gamePath: string; + mainGlob?: string | null; + }): Promise + { + + const system = await emulatorsDb.query.systems.findFirst({ + with: { commands: true }, + where: eq(emulatorSchema.systems.name, data.systemSlug) + }); + + if (!system) + { + throw new Error(`Could not find system '${data.systemSlug}'`); + } + + if (!system.extension || system.extension.length <= 0) + { + throw new Error(`No extensions listed for system '${data.systemSlug}'`); + } + + const downloadPath = config.get('downloadPath'); + const gamePath = path.join(downloadPath, data.gamePath); + + const validFiles: string[] = await this.getRomFilePaths(gamePath, { systemSlug: data.systemSlug, mainGlob: data.mainGlob }); + + function escapeWindowsArg (arg: string): string + { + if (process.platform === 'win32') + { + return `"${arg + .replace(/(\\*)"/g, '$1$1\\"') // escape quotes + .replace(/(\\*)$/, '$1$1') // escape trailing backslashes + }"`; + } else + { + if (arg.includes(' ')) + { + return `"${arg}"`; + } else + { + return arg; + } + } + } + + const formattedCommands = await Promise.all(system.commands + .filter(c => !c.command.includes(`%ENABLESHORTCUTS%`)) + .map(async (command, index) => + { + const label = command.label; + let cmd = command.command; + + let emulator: string | undefined = undefined; + let rom = validFiles[0]; + + if (cmd.includes('%ESCAPESPECIALS%')) + rom = rom.replace(/[&()^=;,]/g, ''); + + + + const staticVars: Record = { + '%ROM%': escapeWindowsArg(rom), + '%ROMRAW%': validFiles[0], + '%ROMRAWWIN%': escapeWindowsArg(validFiles[0].replaceAll('/', '\\')), + '%ESPATH%': escapeWindowsArg(path.dirname(Bun.main)), + '%ROMPATH%': escapeWindowsArg(gamePath), + '%BASENAME%': escapeWindowsArg(path.basename(validFiles[0], path.extname(validFiles[0]))), + '%FILENAME%': escapeWindowsArg(path.basename(validFiles[0])), + '%ESCAPESPECIALS%': "", + '%HIDEWINDOW%': "" + }; + + cmd = cmd.replace(/\%INJECT\%=(?[\w\%.\/\\]+)/g, (_, injectFile: string) => + { + try + { + const resolvedInjectFile = injectFile.replace(this.varRegex, (a) => + { + return staticVars[a] ?? a; + }); + if (existsSync(resolvedInjectFile)) + { + const rawContents = readFileSync(resolvedInjectFile, { encoding: 'utf-8' }); + return rawContents.split('\n').map(v => v.replace('\r', '')).join(' '); + } + + return ''; + } catch (error) + { + return ''; + } + }); + + const matches = Array.from(cmd.matchAll(this.varRegex)); + const varList = await Promise.all(matches.map(async ([value]) => + { + if (value.startsWith("%EMULATOR_")) + { + const emulatorName = value.substring("%EMULATOR_".length, value.length - 1); + let execs = await this.findExecsByName(emulatorName); + let validExec = execs.find(e => e.exists); + + emulator = emulatorName; + return [ + [value, validExec ? validExec.binPath : undefined] as [string, string | undefined], + [`%EMUSOURCE%`, validExec?.type] as [string, string | undefined], + ['%EMUDIR%', validExec?.rootPath ?? (validExec ? escapeWindowsArg(path.dirname(validExec.binPath)) : undefined)] as [string, string | undefined], + ['%EMUDIRRAW%', validExec?.rootPath ?? (validExec ? path.dirname(validExec.binPath) : undefined)] as [string, string | undefined] + ]; + + } + + const key = value[0].substring(1, value.length - 1); + return [[value, process.env[key]] as [string, string | undefined]]; + })); + + const vars = { ...Object.fromEntries(varList.flatMap(l => l)), ...staticVars }; + let startDir: string | undefined = undefined; + + if ('%STARTDIR%' in vars) + { + delete vars['%STARTDIR%']; + + cmd = cmd.replace(this.assignRegex, (match, p1, p2) => + { + if (p1 === '%STARTDIR%') + { + startDir = this.varRegex.test(p2) ? staticVars[p2] : p2; + } + return ""; + }); + } + + // missing variable + const invalid = Object.entries(vars).find(c => c[1] === undefined); + + const formattedCommand = cmd.replace(this.varRegex, (s) => vars[s] ?? '').trim(); + + return { + id: index, + label: label ?? undefined, + command: formattedCommand, + startDir, + valid: !invalid, emulator, + emulatorSource: vars['%EMUSOURCE%'] as any, + metadata: { + romPath: validFiles[0], + emulatorBin: varList.flatMap(l => l).find(v => v[0].includes('%EMULATOR_'))?.[1], + emulatorDir: vars['%EMUDIRRAW%'] + } + } satisfies CommandEntry; + })); + + return formattedCommands.filter(c => !!c); + } + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.emulators.findEmulatorSource.tapPromise(desc.name, async ({ sources, emulator }) => + { + sources.push(...await this.findExecsByName(emulator)); + }); + + ctx.hooks.emulators.findEmulatorForSystem.tapPromise(desc.name, async ({ system, emulators }) => + { + emulators.push(...await this.getEmulatorsForSystem(system)); + }); + + ctx.hooks.games.fetchRomFiles.tapPromise(desc.name, async ({ source, id }) => + { + const localGame = await db.query.games.findFirst({ + where: getLocalGameMatch(id, source), + columns: { path_fs: true, main_glob: true }, + with: { platform: { columns: { es_slug: true } } } + }); + + if (!localGame?.path_fs) + { + return; + } + + const downloadPath = config.get('downloadPath'); + const path_fs = path.join(downloadPath, localGame.path_fs); + + return this.getRomFilePaths(path_fs, { systemSlug: localGame.platform.es_slug ?? undefined, mainGlob: localGame.main_glob }); + }); + + ctx.hooks.games.buildLaunchCommands.tapPromise(desc.name, async ({ systemSlug, source, id, gamePath, mainGlob }) => + { + if (source === 'emulator') + { + const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, id.id) }); + const allExecs = await this.findExecs(id.id, esEmulator); + return allExecs.map(exec => ({ + command: exec.binPath, + id: exec.type, + emulator: id.id, + emulatorSource: exec.type, + metadata: { + emulatorBin: exec.binPath, + emulatorDir: exec.rootPath + }, + valid: true + } satisfies CommandEntry)); + } + + const rommPlatform = systemSlug; + let esSystem: string | undefined = undefined; + const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + if (systemMapping) esSystem = systemMapping.system; + + if (!esSystem) + { + const system = await emulatorsDb.query.systems.findFirst({ where: eq(emulatorSchema.systems.name, systemSlug), columns: { name: true } }); + if (system) esSystem = system.name; + } + + if (esSystem && gamePath) + { + try + { + const commands = await this.getValidLaunchCommands({ systemSlug: esSystem, gamePath, mainGlob }); + + if (cores[esSystem]) + { + const gameUrl = `${RPC_URL(host)}/api/romm/rom/${id.source}/${id.id}`; + commands.push({ + id: 'EMULATORJS', + label: "Emulator JS", + command: `core=${cores[esSystem]}&gameUrl=${encodeURIComponent(gameUrl)}`, + valid: true, + emulator: 'EMULATORJS', + metadata: { + romPath: gameUrl + } + }); + } + + return commands; + } catch (error) + { + console.error(error); + if (error instanceof Error) return error; + } + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json new file mode 100644 index 0000000..9f4d82d --- /dev/null +++ b/src/bun/api/plugins/builtin/launchers/com.simeonradivoev.gameflow.es/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.es", + "displayName": "ES-DE Launcher", + "version": "0.0.1", + "description": "ES-DE Launch Configurations. Used as fallback", + "main": "./es-de.ts", + "icon": "https://impro.usercontent.one/appid/oneComWsb/domain/es-de.org/media/es-de.org/onewebmedia/ES-DE_logo.png", + "category": "launchers", + "keywords": [ + "integration", + "es-de" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json new file mode 100644 index 0000000..20a8525 --- /dev/null +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.rclone", + "displayName": "Rclone Integration", + "version": "0.0.1", + "description": "Rclone integration for syncing saves", + "main": "./rclone.ts", + "icon": "https://forum.rclone.org/uploads/default/original/2X/8/8a14ccd453604987a64820f56c6afa75c229aa17.png", + "category": "saves", + "keywords": [ + "integration", + "rclone" + ] +} \ No newline at end of file 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 new file mode 100644 index 0000000..5c70f3b --- /dev/null +++ b/src/bun/api/plugins/builtin/other/com.simeonradivoev.gameflow.rclone/rclone.ts @@ -0,0 +1,292 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { config, events } from "@/bun/api/app"; +import path, { dirname } from 'node:path'; +import unzip from 'unzip-stream'; +import { ensureDir } from "fs-extra"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import fs from 'node:fs/promises'; +import { randomUUIDv7, sleep } from "bun"; +import z from "zod"; +import { createInterface } from "node:readline"; +import { redirect } from "elysia"; +import { getErrorMessage } from "@/bun/utils"; +import { id } from "zod/v4/locales"; + +const SettingsSchema = z.object({ + runWebGui: z.boolean() + .default(false) + .describe("Run the Web GUI that can be accessed at http://localhost:5572") + .meta({ title: "Run Web GUI" }), + globalConfig: z.boolean().default(false).describe("Use the Global Config file if already setup"), + webGuiPassword: z.string().optional().readonly().describe("Randomly Generated. Read Only. Username is gameflow"), + remoteName: z.string().default(""), + verboseLog: z.boolean() + .default(false) + .describe("Show detailed log of operation for debugging") + .meta({ $comment: JSON.stringify({ category: "debug" }) }) +}); + +type SettingsType = z.infer; +const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm; + +export default class RcloneIntegration implements PluginType +{ + settingsSchema = SettingsSchema; + rclonePath: string | undefined; + server: Bun.Subprocess | undefined; + password: string; + user = "gameflow"; + loginUrl: string | undefined = undefined; + eventsNames = [{ + id: "open-web-gui", + title: "Open Web GUI", + description: "Open Web GUI", + action: "Open" + }, { + id: "refresh", + title: "Refresh Sources", + action: "Refresh" + }]; + + constructor() + { + this.password = randomUUIDv7(); + } + + async onEvent (id: string) + { + switch (id) + { + case "open-web-gui": + return { openTab: this.loginUrl }; + break; + case "refresh": + await this.refresh(); + return { reload: true }; + break; + } + } + + async setup (ctx: PluginLoadingContextType) + { + ctx.zodRegistry.add(SettingsSchema.shape.runWebGui, { requiresRestart: true }); + ctx.zodRegistry.add(SettingsSchema.shape.globalConfig, { requiresRestart: true }); + + const toolsPath = path.join(config.get('downloadPath'), "tools"); + const existingRclones = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + if (existingRclones[0]) + { + this.rclonePath = path.join(toolsPath, existingRclones[0]); + await this.startServer(ctx); + return; + } + + if (await fs.exists(path.join(toolsPath, 'rclone-current-windows-amd64'))) + { + return; + } + + ctx.setProgress(0.5, "Downloading RClone"); + const rcCloseZip = await fetch(`https://downloads.rclone.org/rclone-current-windows-amd64.zip`); + + await ensureDir(toolsPath); + await pipeline(Readable.fromWeb(rcCloseZip.body as any), unzip.Extract({ path: toolsPath })); + const dests = await Array.fromAsync(fs.glob('**/rclone.exe', { cwd: toolsPath })); + if (dests[0]) + { + this.rclonePath = path.join(toolsPath, dests[0]); + await this.startServer(ctx); + return; + } + } + + async refresh () + { + const data = await this.request('/config/listremotes', {}); + z.globalRegistry.add(SettingsSchema.shape.remoteName, { examples: data.remotes, description: "The name of the remote to sync with" }); + } + + async startServer (ctx: PluginLoadingContextType) + { + const args: string[] = []; + if (ctx.config.get('runWebGui')) + { + args.push("--rc-web-gui"); + args.push("--rc-web-gui-no-open-browser"); + } + if (ctx.config.get('')) + { + args.push('-vv'); + } + let env: Record | undefined = undefined; + if (!ctx.config.get('globalConfig')) + { + env = { RCLONE_CONFIG: path.join(config.get('downloadPath'), 'tools', 'config', 'rclone', 'rclone.conf') }; + } + ctx.config.set('webGuiPassword', this.password); + this.server = Bun.spawn([this.rclonePath!, "rcd", '--use-json-log', `--rc-user=${this.user}`, ...args, `--rc-pass=${this.password}`, "--rc-addr", "localhost:5572"], { + stdout: "pipe", + stderr: "pipe", + env + }); + const rl = createInterface({ input: Readable.fromWeb(this.server.stderr as any) }); + rl.on('line', e => + { + const data = JSON.parse(e); + + if (data.level === 'error') + { + console.error(data.msg); + } else + { + console.log(e); + if (loginTokenUrlRegex.test(data.msg)) + { + this.loginUrl = (data.msg as string).match(loginTokenUrlRegex)?.find(e => e); + } + } + + }); + + await new Promise((resolve) => + { + const handleResolve = (line: string) => + { + const data = JSON.parse(line); + if (!loginTokenUrlRegex.test(data.msg)) return; + rl.off('line', handleResolve); + resolve(data); + }; + rl.on('line', handleResolve); + }); + + await this.refresh(); + } + + async request (path: string, body: any) + { + const response = await fetch(`http://localhost:5572${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Basic ${Buffer.from(`${this.user}:${this.password}`).toString('base64')}` + }, + body: JSON.stringify(body) + }); + + const data = await response.json(); + if (response.ok) + { + return data; + } else + { + throw new Error(response.statusText, { cause: data }); + } + } + + async cleanup () + { + await this.request('/core/quit', {}).catch(e => + { + this.server?.kill("SIGKILL"); + }); + + await this.server?.exited; + } + + async load (ctx: PluginLoadingContextType) + { + await this.setup(ctx); + + ctx.hooks.games.prePlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, setProgress, saveFolderSlots }) => + { + if (source !== 'store' || !this.rclonePath || !saveFolderSlots) return; + + for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) + { + + let src: string; + if (ctx.config.get('remoteName')) + { + src = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`; + + const exists = await this.request('/operations/stat', { + fs: `${ctx.config.get('remoteName')}:`, + remote: `gameflow/saves/${source}/${id}/${slot}` + }).catch(e => undefined); + if (!exists || !exists.item) return; + + } else + { + src = path.join(config.get('downloadPath'), 'saves', source, id, slot); + if (!await fs.exists(path.join(config.get('downloadPath'), 'saves', source, id, slot))) return; + } + + setProgress(0.5, "RClone: Syncing Saves"); + + const data = await this.request('/sync/copy', { + srcFs: src, + dstFs: cwd, + createEmptySrcDirs: true, + _config: { + UseJSONLog: true, + LogLevel: "DEBUG", + HumanReadable: true, + Progress: true, + DryRun: true + } + }); + console.log(data); + } + + }); + + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles }) => + { + if (source !== 'store' || !this.rclonePath) return; + console.log("Save Files", Object.values(validChangedSaveFiles).flatMap(c => Array.isArray(c.subPath) ? c.subPath : [c.subPath]).join(",")); + + await Promise.all(Object.entries(validChangedSaveFiles).map(async ([slot, change]) => + { + let dest: string; + if (ctx.config.get('remoteName')) + { + dest = `${ctx.config.get('remoteName')}:gameflow/saves/${source}/${id}/${slot}`; + } else + { + dest = path.join(config.get('downloadPath'), 'saves', source, id, slot); + } + + const data = await this.request('/sync/sync', { + srcFs: change.cwd, + dstFs: dest, + createEmptySrcDirs: true, + _config: { + UseJSONLog: true, + LogLevel: "DEBUG", + HumanReadable: true, + Progress: true + }, + _filter: { + IncludeRule: Array.isArray(change.subPath) ? change.subPath.map(s => + { + if (change.isGlob) return s; + else s.replaceAll('\\', '/'); + }) : change.isGlob ? change.subPath : change.subPath.replaceAll('\\', '/') + } + }).catch(e => + { + events.emit('notification', { message: `RClone: ${e.cause?.error ?? e.message ?? e}`, type: 'error' }); + return undefined; + }); + + if (data) + { + events.emit('notification', { message: "RClone: Save Synced", type: 'success', icon: 'save' }); + } + })); + }); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..78d28b3 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/igdb.ts @@ -0,0 +1,83 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import secrets from "@/bun/api/secrets"; +import PQueue from 'p-queue'; +import * as igdb from '@phalcode/ts-igdb-client'; + +export default class IgdbIntegration implements PluginType +{ + queue: PQueue; + + constructor() + { + this.queue = new PQueue({ concurrency: 8, interval: 1000, intervalCap: 4, strict: true }); + } + + async apiCall (subPath: string, query: string) + { + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + const headers = new Headers({ + "Client-ID": process.env.TWITCH_CLIENT_ID ?? '', + Authorization: `Bearer ${access_token}`, + Accept: "application/json" + }); + const response = await this.queue.add(() => fetch(`https://api.igdb.com/v4${subPath}`, { + headers: headers, + method: "POST", + body: query + })); + if (response.ok) + { + return response.json() as T; + } + } + + async cleanup () + { + this.queue.clear(); + } + + async load (ctx: PluginLoadingContextType) + { + ctx.hooks.games.gameLookup.tapPromise(desc.name, async ({ source, id }) => + { + if (!process.env.TWITCH_CLIENT_ID) return; + if (source !== 'igdb') return; + + const access_token = await secrets.get({ service: 'gamflow_twitch', name: 'access_token' }); + if (access_token) + { + const client = igdb.igdb(process.env.TWITCH_CLIENT_ID, access_token); + const { data } = await client.request('screenshots').pipe(igdb.fields(['game', 'url', 'image_id']), igdb.where('game', '=', Number(id))).execute(); + return { screenshotUrls: data.filter(s => s.url).map(s => `https://images.igdb.com/igdb/image/upload/t_720p/${s.image_id}.webp`) }; + } + }); + + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => + { + let query: string | undefined = undefined; + if (source && id) + { + if (source !== 'igdb') return; + query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where id = ${id};`; + + } + else if (slug) + { + query = `fields name, slug, platform_logo.image_id, platform_logo.url, platform_family.name; where slug = "${slug}";`; + } + + if (query) + { + const data = await this.apiCall<[any]>('/platforms', query); + if (!data || data.length <= 0) return; + return { + slug: data[0].slug, + url_logo: `https://images.igdb.com/igdb/image/upload/t_logo_med/${data[0].platform_logo.image_id}.png`, + name: data[0].name, + family_name: data[0].platform_family?.name + }; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json new file mode 100644 index 0000000..b1cd2e8 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.igdb/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.igdb", + "displayName": "IGDB Integration", + "version": "0.0.1", + "description": "IGDB Metadata Integration", + "main": "./igdb.ts", + "icon": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/19/IGDB_logo.svg/1920px-IGDB_logo.svg.png", + "category": "sources", + "keywords": [ + "integration", + "igdb" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json index 815ddb0..52c2376 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/package.json @@ -5,6 +5,7 @@ "description": "ROMM Server Integration", "main": "./romm.ts", "icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg", + "category": "sources", "keywords": [ "integration", "romm" 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 6193254..6bfbe2d 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,8 +1,8 @@ -import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomByMetadataProviderApiRomsByMetadataProviderGet, getRomContentApiRomsIdContentFileNameGet, getRomFiltersApiRomsFiltersGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +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"; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -12,9 +12,17 @@ import secrets from "@/bun/api/secrets"; import { getAuthToken } from "@/clients/romm/core/auth.gen"; import { client } from "@/clients/romm/client.gen"; import { validateGameSource } from "@/bun/api/games/services/statusService"; +import z from "zod"; -export default class RommIntegration implements PluginType +const SettingsSchema = z.object({ + savesSync: z.boolean().default(false).describe("Experimental save sync support") +}); + +type SettingsType = z.infer; + +export default class RommIntegration implements PluginType { + settingsSchema = SettingsSchema; isSteamDeck = false; orderByMap: Record = { added: "created_at", @@ -54,7 +62,7 @@ export default class RommIntegration implements PluginType { const game: FrontEndGameType = { id: { id: String(rom.id), source: 'romm' }, - path_cover: `/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`, + path_covers: [`/api/romm/image/romm${this.isSteamDeck ? rom.path_cover_small : rom.path_cover_large}`], last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null, updated_at: new Date(rom.created_at), metadata: { @@ -83,8 +91,8 @@ export default class RommIntegration implements PluginType fs_size_bytes: rom.fs_size_bytes, local: false, missing: rom.missing_from_fs, - imdb_id: rom.igdb_id ?? undefined, - ra_id: rom.ra_id ?? undefined, + igdb_id: rom.igdb_id, + ra_id: rom.ra_id, metadata: { age_ratings: rom.metadatum.age_ratings, genres: rom.metadatum.genres, @@ -126,15 +134,12 @@ export default class RommIntegration implements PluginType return detailed; } - async setup () + async load (ctx: PluginLoadingContextType) { this.isSteamDeck = isSteamDeckGameMode(); await this.updateClient(); - } - load (ctx: PluginContextType) - { - ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) => + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => { if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm')) { @@ -146,7 +151,7 @@ export default class RommIntegration implements PluginType limit: query.limit, offset: query.offset, order_by: this.orderByMap[query.orderBy ?? ''], - with_filter_values: true, + with_filter_values: false, genres: query.genres, genres_logic: "all", age_ratings: query.age_ratings, @@ -154,12 +159,6 @@ export default class RommIntegration implements PluginType }, throwOnError: true }); - rommGames.data.filter_values.age_ratings.forEach(r => filters.age_ratings.add(r)); - rommGames.data.filter_values.companies.forEach(r => filters.companies.add(r)); - rommGames.data.filter_values.languages.forEach(r => filters.languages.add(r)); - rommGames.data.filter_values.player_counts.forEach(r => filters.player_counts.add(r)); - rommGames.data.filter_values.genres.forEach(r => filters.genres.add(r)); - games.push(...rommGames.data.items.map(g => { const game: FrontEndGameTypeWithIds = { @@ -172,8 +171,10 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters }) => + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => { + if (source && source !== 'romm') return; + const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true }); rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r)); rommFilters.data.companies.forEach(r => filters.companies.add(r)); @@ -188,7 +189,7 @@ export default class RommIntegration implements PluginType await this.updateClient(); }); - ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id, localGame }) => + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => { if (source !== 'romm') return; @@ -196,13 +197,6 @@ export default class RommIntegration implements PluginType if (rom.data) { const romGame = await this.convertRomToFrontendDetailed(rom.data); - if (localGame) - { - return { - ...romGame, - ...localGame, - }; - } return romGame; } @@ -405,10 +399,12 @@ export default class RommIntegration implements PluginType } }); - ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) => + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, setProgress }) => { - if (source !== 'romm') return; - if (saveFolderPath) + if (source !== 'romm' || !ctx.config.get('savesSync')) return; + if (!saveFolderSlots) return; + + for await (const [slot, { cwd }] of Object.entries(saveFolderSlots)) { setProgress(0, "saves"); @@ -418,53 +414,38 @@ export default class RommIntegration implements PluginType console.error(saveFiles.error); } else { - for (let i = 0; i < saveFiles.data.slots.length; i++) + const rommSlot = saveFiles.data.slots.find(s => s.slot === 'gameflow' && s.latest.file_name_no_tags === slot); + if (rommSlot) { - const slot = saveFiles.data.slots[i]; - const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`); - if (await fs.exists(savePath)) - { - const existingSaveSync = await fs.stat(savePath); - const updatedAtTime = new Date(slot.latest.updated_at).getTime(); - - if (existingSaveSync.mtimeMs > updatedAtTime) - { - console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime); - // Newer file - continue; - } else if (updatedAtTime === existingSaveSync.mtimeMs) - { - //TODO: do checksum comparison when that works on romm - console.log("Same save file", savePath); - continue; - } - } - const auth = await this.getAuthToken(); const headers: Record = {}; if (auth) headers['Authorization'] = auth; - const saveResponse = await fetch(`${config.get('rommAddress')}${slot.latest.download_path}`, { headers }); + const saveResponse = await fetch(`${config.get('rommAddress')}${rommSlot.latest.download_path}`, { headers }); if (!saveResponse.ok) { console.error("Error downloading save", saveResponse.statusText); - break; + return; } - await Bun.write(savePath, saveResponse); - console.log("Loaded", savePath); - setProgress((i / saveFiles.data.slots.length) * 100, "saves"); + + const saveArchive = new Bun.Archive(await saveResponse.blob()); + setProgress(50, "saves"); + const count = await saveArchive.extract(cwd); + setProgress(100, "saves"); + console.log("Loaded", count, "save files"); } } - setProgress(1, "saves"); + setProgress(100, "saves"); await Bun.sleep(1000); } }); - ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ source, id, validChangedSaveFiles, saveFolderPath, command }) => + // Should run after emulators decide on saves + ctx.hooks.games.postPlay.tapPromise({ name: desc.name, stage: 10 }, async ({ source, id, validChangedSaveFiles, command }) => { - if (source !== 'romm') return; + if (source !== 'romm' || !ctx.config.get('savesSync')) return; const sourceValidation = await validateGameSource(source, id); if (!sourceValidation.valid) @@ -473,7 +454,7 @@ export default class RommIntegration implements PluginType return; } - const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared); + /*const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared && !f.isGlob).flatMap(s => Array.isArray(s.subPath) ? s.subPath.map(p => ({ cwd: s.cwd, subPath: p })) : [{ cwd: s.cwd, subPath: s.subPath }]); const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } }); if (saveFiles.error) @@ -494,29 +475,31 @@ export default class RommIntegration implements PluginType if (!finalSavePaths.some(f => f.subPath === subPath)) { // Add newer files to the list, maybe they were changed offscreen. - finalSavePaths.push({ subPath, cwd: saveFolderPath, shared: false }); + finalSavePaths.push({ subPath, cwd: saveFolderPath }); } } } } - } + }*/ + + const finalSavePaths = Object.entries(validChangedSaveFiles).filter(([slot, change]) => !change.isGlob && !change.shared); if (finalSavePaths.length > 0) { - console.log("Files Changed:", finalSavePaths.map(f => f.subPath)?.join(", ")); + console.log("Files Changed:", finalSavePaths.map(([slot, change]) => Array.isArray(change.subPath) ? change.subPath.join(',') : change.subPath)?.join(", ")); - await Promise.all(finalSavePaths.map(async f => + await Promise.all(finalSavePaths.map(async ([slot, change]) => { - const absolutePath = path.join(f.cwd, f.subPath); - if (!await fs.exists(absolutePath)) return; - const stat = await fs.stat(absolutePath); - if (stat.isDirectory()) return; + const savesArray = Array.isArray(change.subPath) ? change.subPath : [change.subPath]; + + // TODO: handle directories + const archive = new Bun.Archive(Object.fromEntries(savesArray.map(s => [s, Bun.file(path.join(change.cwd, s))]))); const data: FormData = new FormData(); - data.append('saveFile', Bun.file(absolutePath), path.basename(f.subPath)); + data.append('saveFile', await archive.blob(), slot); const url = new URL(`${config.get('rommAddress')}/api/saves`); url.searchParams.set('rom_id', id); - url.searchParams.set('slot', path.dirname(f.subPath)); + url.searchParams.set('slot', slot); url.searchParams.set('autocleanup', "true"); url.searchParams.set('autocleanup_limit', "2"); if (command.emulator) @@ -582,11 +565,24 @@ export default class RommIntegration implements PluginType }); - ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id }) => + ctx.hooks.games.platformLookup.tapPromise(desc.name, async ({ source, id, slug }) => { - if (source !== 'romm') return; - const platforms = await this.getAllRommPlatforms(); - return platforms.find(p => p.id === Number(id)); + let platform: PlatformSchema | undefined = undefined; + + if (id && source) + { + if (source !== 'romm') return; + const platforms = await this.getAllRommPlatforms(); + platform = platforms.find(p => p.id === Number(id)); + + } else if (slug) + { + const platforms = await this.getAllRommPlatforms(); + platform = platforms.find(p => p.slug === slug); + } + + if (!platform) return; + return { slug: platform?.slug, url_logo: platform.url_logo, name: platform.display_name, family_name: platform.family_name ?? undefined }; }); ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) => 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 new file mode 100644 index 0000000..713f76f --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/package.json @@ -0,0 +1,13 @@ +{ + "name": "com.simeonradivoev.gameflow.store", + "displayName": "Gameflow Store", + "version": "0.0.1", + "description": "The internal gameflow store", + "main": "./store.ts", + "category": "sources", + "canDisable": false, + "keywords": [ + "internal", + "store" + ] +} \ No newline at end of file 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 new file mode 100644 index 0000000..59e0432 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/services.ts @@ -0,0 +1,313 @@ +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'; +import * as emulatorSchema from '@schema/emulators'; +import { db, emulatorsDb, plugins } from "@/bun/api/app"; +import { and, eq } from "drizzle-orm"; +import { getOrCached } from "@/bun/api/cache"; +import { Glob } from "bun"; +import { shuffleInPlace } from "@/bun/utils"; +import mustache from "mustache"; +import { getEmulatorDownload, getEmulatorPath } from "@/bun/api/store/services/emulatorsService"; +import fs from "node:fs/promises"; + +export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) +{ + const offset = filter?.offset ?? 0; + const limit = Math.min(50, filter?.limit ?? 10); + + const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => + { + return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); + })); + + return games; +} + +export async function getStoreGame (id: string) +{ + const file = Bun.file(path.join(getStoreFolder(), 'buckets', 'games', `${id}.json`)); + if (!(await file.exists())) return undefined; + const game = file + .json() + .then(g => StoreGameSchema.parseAsync(g)) + .then(g => ({ ...g, id })); + return game; +} + +function convertStoreMediaToPath (c: string) +{ + if (c.startsWith('http')) + { + return `/api/romm/image?url=${encodeURIComponent(c)}`; + } else + { + return `/api/store/media/${c}`; + } +} + +export async function convertStoreToFrontend (id: string, storeGame: StoreGameType): Promise +{ + const validDownload = getValidDownload(storeGame); + + let platform_slug: string | null = null; + let platform_id: number | null = null; + let platform_display_name: string | null = null; + let path_platform_cover: string | null = null; + + if (validDownload?.system) + { + let system = validDownload.system.split(':')[0]; + if (system === 'win32') system = 'win'; + + const localPlatform = await db.query.platforms.findFirst({ where: eq(appSchema.platforms.slug, system), columns: { id: true, slug: true, name: true } }); + if (localPlatform) + { + platform_id = localPlatform.id; + platform_slug = localPlatform.slug; + path_platform_cover = `/api/romm/platform/local/${localPlatform.id}/cover`; + platform_display_name = localPlatform.name; + } + + if (platform_slug === null) + { + const rommSystem = await emulatorsDb.query.systemMappings.findFirst({ + where: and(eq(emulatorSchema.systemMappings.sourceSlug, system), eq(emulatorSchema.systemMappings.source, 'romm')) + }); + + if (rommSystem?.system) + { + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, rommSystem?.system), + columns: { fullname: true } + }); + + platform_slug = rommSystem.system; + platform_display_name = platformDef?.fullname ?? null; + path_platform_cover = `/api/romm/image/romm/assets/platforms/${rommSystem.sourceSlug}.svg`; + + } else + { + const platformDef = await emulatorsDb.query.systems.findFirst({ + where: eq(emulatorSchema.systems.name, system), + columns: { fullname: true } + }); + + platform_slug = system; + platform_display_name = platformDef?.fullname ?? null; + } + + platform_slug ??= system; + } + } + + + const game: FrontEndGameType = { + platform_display_name, + path_platform_cover, + id: { source: 'store', id: id }, + source: null, + source_id: null, + path_fs: null, + path_covers: storeGame.covers?.map(convertStoreMediaToPath) ?? [], + last_played: null, + updated_at: new Date(), + slug: id, + name: storeGame.name, + platform_id, + platform_slug, + paths_screenshots: storeGame.screenshots?.map((s: string) => `/api/romm/image?url=${encodeURIComponent(s)}`) ?? [], + metadata: { + first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null + } + }; + + return game; +} + + +export async function convertStoreToFrontendDetailed (id: string, storeGame: StoreGameType): Promise +{ + const validDownload = getValidDownload(storeGame); + let size: number | null = null; + if (validDownload?.url) + { + try + { + const fileResponse = await fetch(validDownload?.url, { method: 'HEAD' }); + size = Number(fileResponse.headers.get('content-length')); + } catch (error) + { + console.error(error); + } + } + + const detailed: FrontEndGameTypeDetailed = { + ...await convertStoreToFrontend(id, storeGame), + summary: storeGame.description, + fs_size_bytes: size, + missing: false, + local: false, + version: storeGame.version, + igdb_id: storeGame.igdb_id ?? null, + ra_id: storeGame.ra_id ?? null, + metadata: { + genres: storeGame.genres ?? [], + companies: storeGame.companies ?? [], + game_modes: [], + age_ratings: [], + player_count: storeGame.player_count ?? null, + average_rating: null, + first_release_date: typeof storeGame.first_release_date === 'number' ? new Date(storeGame.first_release_date) : storeGame.first_release_date ?? null + } + }; + + return detailed; +} + +export function getValidDownload (game: StoreGameType, downloadId?: string) +{ + const downloads = Object.entries(game.downloads).map(([k, d]) => ({ id: k, ...d })); + const supportedDownloads = downloads.filter(d => d.type === 'direct'); + + if (downloadId) + { + return supportedDownloads.find(d => d.id === downloadId); + } else + { + return supportedDownloads.find(d => d.system === `${process.platform}:${process.arch}`) + ?? supportedDownloads.find(d => + { + // Linux supports proton, can run windows games + if (process.platform === 'linux') return d.system === `win32:${process.arch}`; + return false; + }) + // Fallback to emulator platforms + ?? supportedDownloads.find(d => !d.system.includes(':')); + } +} + +export async function getShuffledStoreGames () +{ + return getOrCached('shuffled-store-games', async () => + { + const files = new Glob(path.join(getStoreFolder(), 'buckets', 'games', '*.json')).scan(); + const allGamePaths = await Array.fromAsync(files); + const allStoreGames = await Promise.all(allGamePaths.map(p => Bun.file(p).json().then(g => StoreGameSchema.parseAsync(g)).then(g => ({ ...g, id: path.basename(p, '.json') })))); + shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); + return allStoreGames; + }, { expireMs: 1000 / 60 / 60 }); +} + +export async function buildFilters (filters: FrontEndFilterSets) +{ + const filtersFile = Bun.file(path.join(getStoreFolder(), 'manifests', 'filters.json')); + if (!await filtersFile.exists()) return; + const storeFilters = await filtersFile.json(); + + storeFilters.genres?.forEach((g: string) => filters.genres.add(g)); + storeFilters.age_ratings?.forEach((g: string) => filters.age_ratings.add(g)); + if (storeFilters.player_count) + filters.player_counts.add(storeFilters.player_count); + storeFilters.companies?.forEach((g: string) => filters.companies.add(g)); +} + +function getAppData () +{ + if (process.platform === "win32") return process.env.APPDATA!; + if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support"); + // linux + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); +} + +function getLocalAppData () +{ + if (process.platform === "win32") return process.env.LOCALAPPDATA!; + if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches"); + // Linux / Unix + return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); +} + +export function buildSaves (command: CommandEntry, storeGame: StoreGameType, download?: StoreDownloadType) +{ + let saveFileGlobs: Record | undefined = undefined; + if (download && download.saves) + { + saveFileGlobs = download.saves; + + } else if (storeGame.saves) + { + const platformSaves = storeGame.saves[`${process.platform}:${process.arch}`]; + if (platformSaves) + { + saveFileGlobs = platformSaves; + } + } + + const view = { + GAMEDIR: command.startDir, + HOMEDIR: os.homedir(), + TMPDIR: os.tmpdir(), + APPDATA: getAppData(), + LOCALAPPDATA: getLocalAppData(), + }; + + if (!saveFileGlobs) return; + + return Object.entries(saveFileGlobs).map(([slot, save]) => + { + const cwd = mustache.render(save.cwd, view); + const change: SaveFileChange = { + cwd, + shared: false, + isGlob: true, + subPath: save.globs + }; + return [slot, change] as [string, SaveFileChange]; + }); +} + +export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, systems: EmulatorSystem[]) +{ + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: emulator.name, sources: execPaths }); + + const em: FrontEndEmulator = { + name: emulator.name, + logo: emulator.logo, + systems, + gameCount: 0, + validSources: execPaths, + integrations: [] + }; + + return em; +} + +export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> +{ + const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; + if (await fs.exists(existingPackagePath)) + { + const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); + const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); + if (!download) return { ...existingPackage, hasUpdate: false }; + if (download.info.version) + { + if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; + } else if (existingPackage.id !== download.info.id) + { + return { ...existingPackage, hasUpdate: true }; + } + + return { ...existingPackage, hasUpdate: false }; + } + + // this should only happen if download info is missing maybe manually deleted or wasn't saved. + return undefined; +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts new file mode 100644 index 0000000..e3dec17 --- /dev/null +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.store/store.ts @@ -0,0 +1,312 @@ +import { PluginLoadingContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import path, { basename, dirname } from 'node:path'; +import { StoreDownloadType, StoreGameSchema, StoreGameType } from "@/shared/constants"; +import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "@/bun/api/store/services/gamesService"; +import { Glob, pathToFileURL } from "bun"; +import { getOrCached } from "@/bun/api/cache"; +import { shuffleInPlace } from "@/bun/utils"; +import { and, eq } from "drizzle-orm"; +import * as emulatorSchema from '@schema/emulators'; + +import { config, db, emulatorsDb, plugins, taskQueue } from "@/bun/api/app"; +import fs from "node:fs/promises"; +import { getSourceGameDetailed } from "@/bun/api/games/services/utils"; +import mustache from "mustache"; +import os from 'node:os'; +import UpdateStoreJob from "@/bun/api/jobs/update-store"; +import { getEmulatorDownload } from "@/bun/api/store/services/emulatorsService"; +import { buildFilters, buildSaves, convertStoreEmulatorToFrontend, convertStoreToFrontend, convertStoreToFrontendDetailed, getExistingStoreEmulatorDownload, getShuffledStoreGames, getStoreGame, getValidDownload } from "./services"; + +export default class RommIntegration implements PluginType +{ + async setup (ctx: PluginLoadingContextType) + { + console.log("Store Directory is ", getStoreFolder()); + ctx.setProgress(0, "Updating Store"); + await taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob()); + } + + async load (ctx: PluginLoadingContextType) + { + + ctx.hooks.store.fetchDownload.tapPromise(desc.name, async ({ id }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); + return downloadInfo; + }); + + ctx.hooks.store.fetchEmulator.tapPromise(desc.name, async ({ id }) => + { + const emulatorPackage = await getStoreEmulatorPackage(id); + if (!emulatorPackage) return undefined; + + const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); + + const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); + const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; + const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); + const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; + const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); + + const emulator: FrontEndEmulatorDetailed = { + name: emulatorPackage.name, + description: emulatorPackage.description, + source: "store", + systems, + validSources: [], + screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), + gameCount: 0, + homepage: emulatorPackage.homepage, + downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => + { + const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); + return download?.info; + }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), + logo: emulatorPackage.logo, + biosRequirement: emulatorPackage.bios, + bios: biosFiles, + integrations: [], + storeDownloadInfo: storeDownloadInfo + }; + + return emulator; + }); + + ctx.hooks.store.fetchEmulators.tapPromise(desc.name, async ({ emulators, search }) => + { + const emulatesParsed = await getAllStoreEmulatorPackages(); + emulators.push(...await Promise.all(emulatesParsed + .filter(e => + { + if (!e.os.includes(process.platform as any)) return false; + if (search) + { + if (e.name.toLocaleLowerCase().includes(search) || e.systems.some(s => s.toLocaleLowerCase().includes(search)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(search))) + { + return true; + } + + return false; + } + return true; + }) + .map(async (emulator) => + { + const systems = await buildStoreFrontendEmulatorSystems(emulator); + return convertStoreEmulatorToFrontend(emulator, systems); + }))); + }); + + ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderSlots, command }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + const localGame = await getSourceGameDetailed(source, id); + + if (!localGame || !storeGame) return; + if (!localGame.version_source) return; + + const download = storeGame.downloads[localGame.version_source]; + const saves = buildSaves(command, storeGame, download); + + saves?.forEach(([slot, save]) => saveFolderSlots[slot] = { cwd: save.cwd }); + }); + + ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ validChangedSaveFiles, source, id, command }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + const localGame = await getSourceGameDetailed(source, id); + + if (!localGame || !storeGame) return; + if (!localGame.version_source) return; + + const download = storeGame.downloads[localGame.version_source]; + + const saves = buildSaves(command, storeGame, download); + saves?.forEach(([key, val]) => validChangedSaveFiles[key] = val); + }); + + ctx.hooks.games.buildLaunchCommands.tapPromise({ name: desc.name, before: 'com.simeonradivoev.gameflow.es' }, async ({ gamePath, source, sourceId, systemSlug, mainGlob }) => + { + if (source !== 'store' || !gamePath || systemSlug !== 'win') return; + const downloadPath = config.get('downloadPath'); + const gamePathAbsolute = path.join(downloadPath, gamePath); + if (!(await fs.exists(gamePathAbsolute))) return; + const gamePathStat = await fs.stat(gamePathAbsolute); + + if (gamePathStat.isDirectory()) + { + const fileGlob = new Glob(mainGlob ?? '**/*.exe'); + for await (const file of fileGlob.scan({ cwd: path.join(downloadPath, gamePath) })) + { + return [{ + startDir: path.join(downloadPath, gamePath, dirname(file)), + command: basename(file), + id: 'store-win', + valid: true, + env: { + XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') + }, + metadata: { + romPath: path.join(downloadPath, gamePath, file) + } + }]; + } + + } else + { + return [{ + startDir: path.join(downloadPath, dirname(gamePath)), + command: basename(gamePath), + env: { + XDG_DATA_HOME: path.join(config.get('downloadPath'), 'save', source, sourceId ?? '') + }, + id: 'store-win', + valid: true, + metadata: { + romPath: path.join(downloadPath, gamePath) + } + }]; + } + + }); + + ctx.hooks.games.fetchFilters.tapPromise(desc.name, async ({ filters, source }) => + { + if (!source || source !== 'store') return; + await buildFilters(filters); + }); + + ctx.hooks.store.fetchFeaturedGames.tapPromise(desc.name, async ({ games }) => + { + const allGames = await getShuffledStoreGames(); + const convertedGames = await Promise.all(allGames.slice(0, 3).map(async g => + { + return convertStoreToFrontendDetailed(g.id, g); + })); + games.push(...convertedGames); + }); + + ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) => + { + if (!query.source || query.source !== 'store') return; + if (query.collection_source || query.collection_id) return; + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames.filter(g => + { + if (query.search) + return path.basename(g.name).toLocaleLowerCase().includes(query.search.toLocaleLowerCase()); + return true; + }) + .slice(query.offset ?? 0, Math.min((query.offset ?? 0) + (query.limit ?? 50), shuffledGames.length)) + .map(async (e) => + { + const game: FrontEndGameTypeWithIds = { + ...await convertStoreToFrontend(e.id, e), + igdb_id: e.igdb_id ?? null, + ra_id: e.ra_id ?? null + }; + return game; + })); + games.push(...storeGames.filter(g => g !== undefined)); + }); + + ctx.hooks.games.fetchRecommendedGamesForGame.tapPromise(desc.name, async ({ game, games }) => + { + const esSystem = game.platform_slug ? await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.source, 'romm'), eq(emulatorSchema.systemMappings.sourceSlug, game.platform_slug)), columns: { system: true } }) : undefined; + + const shuffledGames = await getShuffledStoreGames(); + const storeGames = await Promise.all(shuffledGames + .filter(g => + { + if (esSystem) + { + if (Object.values(g.downloads).some(d => d.system === esSystem.system)) return true; + } + + return false; + }) + .map(async (e) => + { + return convertStoreToFrontend(e.id, e); + })); + + if (storeGames) + { + games.push(...storeGames.slice(0, 3)); + } + }); + + ctx.hooks.games.fetchRecommendedGamesForEmulator.tapPromise(desc.name, async ({ emulator, games, systems }) => + { + const systemsIdSet = new Set(systems.map(s => s.id)); + const gamesManifest = await getShuffledStoreGames(); + const storeGames = await Promise.all(gamesManifest + .filter(g => Object.values(g.downloads).some(d => systemsIdSet.has(d.system))) + .map(async (e) => + { + + return convertStoreToFrontend(e.id, e); + })); + + games.push(...storeGames.filter(g => g !== undefined).slice(0, 3)); + }); + + ctx.hooks.games.fetchGame.tapPromise(desc.name, async ({ source, id }) => + { + if (source !== 'store') return; + const storeGame = await getStoreGame(id); + if (storeGame) + { + return convertStoreToFrontendDetailed(id, storeGame); + } + }); + + ctx.hooks.games.fetchDownloads.tapPromise(desc.name, async ({ source, id, downloadId }) => + { + if (source !== 'store') return; + const game = await getStoreGame(id); + if (!game) throw new Error("Missing Store Game"); + + const validDownload = getValidDownload(game, downloadId); + + if (validDownload) + { + let system = validDownload.system.split(":")[0]; + if (system === 'win32') system = 'win'; + + const info: DownloadInfo = { + coverUrl: game.covers?.[0] ? game.covers[0].startsWith('http') ? game.covers[0] : pathToFileURL(path.join(getStoreFolder(), game.covers[0])).href : "", + screenshotUrls: game.screenshots ?? [], + files: [{ + url: new URL(validDownload.url), + file_path: `roms/${system}`, + file_name: path.basename(decodeURI(validDownload.url)), + size: 0 + }], + slug: id, + source_id: id, + name: game.name, + summary: game.description, + system_slug: system, + path_fs: path.join('roms', system, game.id), + extract_path: '.', + main_glob: validDownload.main, + version: game.version, + version_system: validDownload.system, + version_source: validDownload.id, + platform: { + slug: system, + name: system + } + }; + + return info; + } + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/plugin-manager.ts b/src/bun/api/plugins/plugin-manager.ts index 90392f1..e22b2c2 100644 --- a/src/bun/api/plugins/plugin-manager.ts +++ b/src/bun/api/plugins/plugin-manager.ts @@ -1,6 +1,15 @@ import { GameflowHooks } from "../hooks/app"; -import { PluginContextType, PluginDescriptionType, PluginType } from "../../types/typesc.schema"; +import { PluginDescriptionType, PluginLoadingContextType, PluginType } from "../../types/typesc.schema"; import { config } from "../app"; +import Conf from "conf"; +import projectPackage from '~/package.json'; +import z from "zod"; +import { EventEmitter } from "node:stream"; + +export const pluginZodRegistry = z.registry<{ + requiresRestart?: boolean; + readOnly?: boolean; +}>(); export class PluginManager { @@ -11,10 +20,11 @@ export class PluginManager plugin: PluginType; description: PluginDescriptionType, source: PluginSourceType; + config?: Conf; }> = {}; - async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) + register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType) { try { @@ -24,15 +34,29 @@ export class PluginManager } else { - if (plugin.setup) await plugin.setup(); + let pluginConfig: Conf | undefined = undefined; + if (plugin.settingsSchema) + { + pluginConfig = new Conf({ + projectName: projectPackage.name, + configName: description.name, + projectSuffix: 'bun', + cwd: process.env.CONFIG_CWD, + schema: Object.fromEntries(Object.entries(plugin.settingsSchema.shape).map(([key, schema]) => [key, (schema as z.ZodObject).toJSONSchema() as any])) as any, + defaults: plugin.settingsSchema.parse({}), + migrations: plugin.settingsMigrations as any, + projectVersion: description.version + }); + } + this.plugins[description.name] = { enabled: !config.get('disabledPlugins').includes(description.name), loaded: false, plugin: plugin, source: source, - description: description + description: description, + config: pluginConfig }; - this.reload(description.name); console.log("Plugin", description.name, "registered"); } @@ -44,24 +68,29 @@ export class PluginManager }; } - private reload (name: string) + private async reload (name: string, reloadCtx: { setProgress: (progress: number, state: string) => void; }) { const plugin = this.plugins[name]; if (plugin) { - const ctx: PluginContextType = { hooks: this.hooks }; + const ctx: PluginLoadingContextType = { + hooks: this.hooks, + setProgress: reloadCtx.setProgress.bind(reloadCtx), + config: plugin.config as any, + zodRegistry: pluginZodRegistry + }; if (plugin.loaded) { - plugin.plugin.onBeforeReload?.(ctx); + await plugin.plugin.cleanup?.(); plugin.loaded = false; } try { - if (plugin.enabled) + if (plugin.enabled || plugin.description.canDisable === false) { - plugin.plugin.load(ctx); + await plugin.plugin.load(ctx); plugin.loaded = true; } } catch (error) @@ -72,10 +101,14 @@ export class PluginManager } } - reloadAll () + async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; }) { this.hooks = new GameflowHooks(); - Object.keys(this.plugins).forEach(id => this.reload(id)); + for await (const id of Object.keys(this.plugins)) + { + ctx.setProgress(0, `Loading ${id}`); + await this.reload(id, ctx); + } } async cleanup () @@ -84,7 +117,10 @@ export class PluginManager { try { - await p.plugin.cleanup!(); + if (p.loaded) + { + await p.plugin.cleanup!(); + } } catch (error) { console.log("Error for plugin", p.description.name, "while cleaning up"); diff --git a/src/bun/api/plugins/plugins.ts b/src/bun/api/plugins/plugins.ts index e276f92..6a2dedc 100644 --- a/src/bun/api/plugins/plugins.ts +++ b/src/bun/api/plugins/plugins.ts @@ -1,7 +1,8 @@ import Elysia, { status } from "elysia"; -import { plugins } from "../app"; +import { plugins, taskQueue } from "../app"; import z from "zod"; import { toggleElementInConfig } from "@/bun/utils"; +import ReloadPluginsJob from "../jobs/reload-plugins-job"; export default new Elysia({ prefix: '/plugins' }) .get('/', async () => @@ -15,19 +16,31 @@ export default new Elysia({ prefix: '/plugins' }) description: p.description.description, source: p.source, version: p.description.version, - icon: p.description.icon + canDisable: p.description.canDisable ?? true, + icon: p.description.icon, + category: p.description.category, + hasSettings: !!p.config }; return plugin; }); }) + .get('/:id', async ({ params: { id } }) => + { + const plugin = plugins.plugins[id]; + return plugin.description; + }) .post('/:id', async ({ params: { id }, body: { enabled } }) => { const plugin = plugins.plugins[id]; if (plugin) { + if (plugin.description.canDisable === false) + { + return status("Forbidden"); + } plugin.enabled = enabled; toggleElementInConfig('disabledPlugins', plugin.description.name, enabled); - plugins.reloadAll(); + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); } else { return status("Not Found"); diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 3c49311..5f542ae 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -7,11 +7,14 @@ import cemu from './builtin/emulators/com.simeonradivoev.gameflow.cemu/package.j import xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json'; import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; +import igdb from './builtin/sources/com.simeonradivoev.gameflow.igdb/package.json'; +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/typesc.schema"; export default async function register (pluginManager: PluginManager) { - const plugins: (PluginDescriptionType & { main: string; load: () => Promise; })[] = [ { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, @@ -20,9 +23,24 @@ export default async function register (pluginManager: PluginManager) { ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') }, { ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, + { ...igdb, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.igdb/igdb') }, + { ...es, load: () => import('./builtin/launchers/com.simeonradivoev.gameflow.es/es-de') }, + { ...store, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.store/store') }, + { ...rclone, load: () => import('./builtin/other/com.simeonradivoev.gameflow.rclone/rclone') }, ]; - await Promise.all(plugins.map(async (pluginPackage) => + await Promise.all(plugins.filter(p => + { + if (process.env.PLUGIN_WHITELIST && !process.env.PLUGIN_WHITELIST.includes(p.name)) + { + return false; + } + 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') diff --git a/src/bun/api/schema/app.ts b/src/bun/api/schema/app.ts index 35c9c5a..2226b20 100644 --- a/src/bun/api/schema/app.ts +++ b/src/bun/api/schema/app.ts @@ -9,6 +9,7 @@ export const games = sqliteTable('games', { name: text("name"), ra_id: integer('ra_id').unique(), path_fs: text("path_fs"), + main_glob: text("main_glob"), last_played: integer("last_played", { mode: 'timestamp' }), created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(), metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{ @@ -24,7 +25,10 @@ export const games = sqliteTable('games', { platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(), cover: blob("cover", { mode: 'buffer' }), cover_type: text('type'), - summary: text("summary") + summary: text("summary"), + version: text('version'), + version_source: text("version_source"), + version_system: text("version_system"), }); export const gamesRelations = relations(games, ({ many, one }) => ({ diff --git a/src/bun/api/settings/services.ts b/src/bun/api/settings/services.ts index 7b7a89e..ea76797 100644 --- a/src/bun/api/settings/services.ts +++ b/src/bun/api/settings/services.ts @@ -2,10 +2,9 @@ import * as appSchema from '@schema/app'; import * as emulatorSchema from "@schema/emulators"; import { eq, inArray } from 'drizzle-orm'; -import { db, emulatorsDb } from '../app'; +import { db, emulatorsDb, plugins } from '../app'; import { cores } from '../emulatorjs/emulatorjs'; import { SERVER_URL } from '@/shared/constants'; -import { findExecsByName } from '../games/services/launchGameService'; import { host } from '@/bun/utils/host'; import { findEmulatorPluginIntegration } from '../store/services/emulatorsService'; @@ -54,7 +53,18 @@ export async function getRelevantEmulators () const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator); const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) => { - const execPaths = await findExecsByName(emulator); + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(emulator, execPaths); + + const storeEmulator = await plugins.hooks.store.fetchEmulator.promise({ id: emulator }); + + if (storeEmulator) + { + storeEmulator.validSources = execPaths; + storeEmulator.integrations = integrations; + return storeEmulator; + } let platform: number | null | undefined = null; const validSystemSlug = system_slug.find(s => s.system); @@ -75,7 +85,7 @@ export async function getRelevantEmulators () gameCount: 0, isCritical: false, validSources: execPaths, - integrations: findEmulatorPluginIntegration(emulator, execPaths) + integrations }; return em; diff --git a/src/bun/api/settings/settings.ts b/src/bun/api/settings/settings.ts index dda53b9..c315701 100644 --- a/src/bun/api/settings/settings.ts +++ b/src/bun/api/settings/settings.ts @@ -1,12 +1,15 @@ import z from "zod"; import { SettingsSchema } from "@shared/constants"; import Elysia, { status } from "elysia"; -import { config, customEmulators, taskQueue } from "../app"; +import { config, customEmulators, plugins, taskQueue } from "../app"; import fs from 'node:fs/promises'; import { existsSync } from "node:fs"; import { InstallJob } from "../jobs/install-job"; import { move } from "fs-extra"; import { getRelevantEmulators } from "./services"; +import type { JSONSchema7 } from "json-schema"; +import ReloadPluginsJob from "../jobs/reload-plugins-job"; +import { pluginZodRegistry } from "../plugins/plugin-manager"; export const settings = new Elysia({ prefix: '/api/settings' }) .get('/emulators/automatic', async () => @@ -77,18 +80,59 @@ export const settings = new Elysia({ prefix: '/api/settings' }) drive: z.string().optional() }) }) - .get("/:id", async ({ params: { id } }) => + .get("local/:id", async ({ params: { id } }) => { const value = config.get(id); return { value: value }; }, { params: z.object({ id: z.keyof(SettingsSchema) }), - }).post('/:id', + }).post('local/:id', async ({ params: { id }, body: { value }, }) => { config.set(id, value); }, { params: z.object({ id: z.keyof(SettingsSchema) }), body: z.object({ value: z.any() }), - }); + }) + .get('/definitions/:source', async ({ params: { source } }) => + { + return plugins.plugins[source].plugin.settingsSchema?.toJSONSchema() as JSONSchema7; + }) + .get('/actions/:source', async ({ params: { source } }) => + { + const plugin = plugins.plugins[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); + }) + .get('/:source/:id', async ({ params: { source, id } }) => + { + return { value: plugins.plugins[source].config?.get(id) }; + }) + .put('/:source/:id', async ({ params: { source, id }, body: { value } }) => + { + const plugin = plugins.plugins[source]; + if (!plugin.config) return status("Not Found", "Plugin has no config"); + const settingSchema = plugin.plugin.settingsSchema?.shape[id] as z.ZodObject; + if (!settingSchema) return status("Not Found", "Could not find setting"); + const meta = pluginZodRegistry.get(settingSchema); + + if (meta?.readOnly) + { + return; + } + + plugin.config?.set(id, value); + + if (meta?.requiresRestart) + { + await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob()); + } + }, + { + body: z.object({ value: z.any() }) + }); diff --git a/src/bun/api/store/services/emulatorsService.ts b/src/bun/api/store/services/emulatorsService.ts index 1682ecb..d29a1be 100644 --- a/src/bun/api/store/services/emulatorsService.ts +++ b/src/bun/api/store/services/emulatorsService.ts @@ -1,34 +1,7 @@ -import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; -import { config, emulatorsDb, plugins } from "../../app"; -import * as emulatorSchema from '@schema/emulators'; -import { findExecs } from "../../games/services/launchGameService"; -import { eq } from "drizzle-orm"; +import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants"; +import { config, plugins } from "../../app"; import { getOrCached, getOrCachedGithubRelease } from "../../cache"; import path from "node:path"; -import fs from "node:fs/promises"; - -export async function convertStoreEmulatorToFrontend (emulator: EmulatorPackageType, gameCount: number, systems: EmulatorSystem[]) -{ - const execPaths: EmulatorSourceEntryType[] = []; - const esEmulator = await emulatorsDb.query.emulators.findFirst({ where: eq(emulatorSchema.emulators.name, emulator.name) }); - - if (esEmulator) - { - const allExecs = await findExecs(emulator.name, esEmulator); - execPaths.push(...allExecs); - } - - const em: FrontEndEmulator = { - name: emulator.name, - logo: emulator.logo, - systems, - gameCount, - validSources: execPaths, - integrations: findEmulatorPluginIntegration(emulator.name, execPaths) - }; - - return em; -} export function findEmulatorPluginIntegration (name: string, validSources: (EmulatorSourceEntryType | undefined)[]): EmulatorSupport[] { @@ -52,29 +25,6 @@ export function getEmulatorPath (emulator: string) return path.join(config.get('downloadPath'), "emulators", emulator); } -export async function getExistingStoreEmulatorDownload (emulator: EmulatorPackageType): Promise<(EmulatorDownloadInfoType & { hasUpdate: boolean; }) | undefined> -{ - const existingPackagePath = `${getEmulatorPath(emulator.name)}.json`; - if (await fs.exists(existingPackagePath)) - { - const existingPackage = await EmulatorDownloadInfoSchema.parseAsync(await Bun.file(existingPackagePath).json()); - const download = await getEmulatorDownload(emulator, existingPackage.type).catch(d => undefined); - if (!download) return { ...existingPackage, hasUpdate: false }; - if (download.info.version) - { - if (existingPackage.version !== download.info.version) return { ...existingPackage, hasUpdate: true }; - } else if (existingPackage.id !== download.info.id) - { - return { ...existingPackage, hasUpdate: true }; - } - - return { ...existingPackage, hasUpdate: false }; - } - - // this should only happen if download info is missing maybe manually deleted or wasn't saved. - return undefined; -} - export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string) { if (!emulator.downloads) throw new Error("Emulator has no downloads"); diff --git a/src/bun/api/store/services/gamesService.ts b/src/bun/api/store/services/gamesService.ts index 99aa15a..17ee6ec 100644 --- a/src/bun/api/store/services/gamesService.ts +++ b/src/bun/api/store/services/gamesService.ts @@ -6,74 +6,9 @@ import path from "node:path"; import fs from 'node:fs/promises'; import * as emulatorSchema from '@schema/emulators'; import { shuffleInPlace } from "@/bun/utils"; +import { Glob } from "bun"; -export async function getShuffledStoreGames () -{ - return getOrCached('shuffled-store-games', async () => - { - const gamesManifest = await getStoreGameManifest(); - const allStoreGames = gamesManifest.filter(g => g.type === 'blob'); - shuffleInPlace(allStoreGames, Math.round(new Date().getTime() / 1000 / 60 / 60)); - return allStoreGames; - }, { expireMs: 1000 / 60 / 60 }); -} -export async function getStoreGameManifest () -{ - return getOrCached(CACHE_KEYS.STORE_GAME_MANIFEST, async () => - { - const store = await fetch('https://api.github.com/repos/dragoonDorise/EmuDeck/git/trees/50261b66d69c1758efa28c6d7c54e45259a0c9c5?recursive=true').then(r => r.json()).then(data => GithubManifestSchema.parseAsync(data)); - - return store.tree.filter((e: any) => - { - if (e.type === 'blob' && e.path !== "featured.json") - { - return true; - } - return false; - }); - }); -} - -export async function getStoreGames (gamesManifest: any[], filter?: { limit?: number; offset?: number; }) -{ - const offset = filter?.offset ?? 0; - const limit = Math.min(50, filter?.limit ?? 10); - - const games = await Promise.all(gamesManifest.slice(offset, Math.min(offset + limit, gamesManifest.length)).map((e: any) => - { - return fetch(e.url).then(e => e.json()).then(game => StoreGameSchema.parseAsync(JSON.parse(atob(game.content.replace(/\n/g, ""))))); - })); - - return games; -} - -export function extractStoreGameSourceId (id: string) -{ - const gameId = id.split('@'); - if (gameId.length !== 2) - throw new Error("Store ID should include platform and name with @ separator"); - return { system: gameId[0], id: gameId[1] }; -} - -export function getStoreGameFromId (id: string) -{ - const data = extractStoreGameSourceId(id); - return getStoreGame(data.system, data.id); -} - -export async function getStoreGame (system: string, id: string) -{ - return getStoreGameFromPath(`${system}/${encodeURIComponent(id)}.json`); -} - -export async function getStoreGameFromPath (path: string) -{ - const game = await getOrCached(CACHE_KEYS.STORE_GAME(path), () => fetch(`https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/${path}`) - .then(e => e.json()) - .then(g => StoreGameSchema.parseAsync(g))); - return game; -} export function getStoreRootFolder () { diff --git a/src/bun/api/store/store.ts b/src/bun/api/store/store.ts index 5145232..b15f5b6 100644 --- a/src/bun/api/store/store.ts +++ b/src/bun/api/store/store.ts @@ -1,19 +1,18 @@ import Elysia, { status } from "elysia"; -import { config, db, taskQueue } from "../app"; +import { config, db, plugins, taskQueue } from "../app"; import path from "node:path"; import fs from 'node:fs/promises'; -import { EmulatorDownloadInfoSchema, StoreGameSchema } from "@/shared/constants"; -import { findExecsByName } from "../games/services/launchGameService"; +import { EmulatorDownloadInfoSchema } from "@/shared/constants"; import * as appSchema from '@schema/app'; import z from "zod"; -import { convertLocalToFrontendDetailed, convertStoreToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; +import { convertLocalToFrontendDetailed, getLocalGameMatch } from "../games/services/utils"; import { getPlatformsApiPlatformsGet } from "@/clients/romm"; import { CACHE_KEYS, getOrCached } from "../cache"; -import { buildStoreFrontendEmulatorSystems, getAllStoreEmulatorPackages, getStoreEmulatorPackage, getStoreFolder } from "./services/gamesService"; +import { getStoreFolder } from "./services/gamesService"; import { EmulatorDownloadJob } from "../jobs/emulator-download-job"; -import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService"; import { BiosDownloadJob } from "../jobs/bios-download-job"; +import { findEmulatorPluginIntegration } from "./services/emulatorsService"; export const store = new Elysia({ prefix: '/api/store' }) .get('/emulators', async ({ query }) => @@ -23,42 +22,32 @@ export const store = new Elysia({ prefix: '/api/store' }) console.error(e); return undefined; }); - const emulatesParsed = await getAllStoreEmulatorPackages(); - let frontEndEmulators = await Promise.all(emulatesParsed - .filter(e => + + + let frontEndEmulators: FrontEndEmulator[] = []; + await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators, search: query.search }); + + await Promise.all(frontEndEmulators.map(async e => + { + const gameCounts = e.systems.map((s) => { - if (!e.os.includes(process.platform as any)) return false; - if (query.search) + const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); + if (romPlatform) { - const lowerCaseSearch = query.search.toLocaleLowerCase(); - - if (e.name.toLocaleLowerCase().includes(lowerCaseSearch) || e.systems.some(s => s.toLocaleLowerCase().includes(lowerCaseSearch)) || e.keywords?.some(k => k.toLocaleLowerCase().includes(lowerCaseSearch))) - { - return true; - } - - return false; + return romPlatform.rom_count; } - return true; - }) - .map(async (emulator) => - { - const systems = await buildStoreFrontendEmulatorSystems(emulator); - const gameCounts = await Promise.all(systems.map(async (s) => - { - const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id)); - if (romPlatform) - { - return romPlatform.rom_count; - } - return 0; + return 0; - })); + }); - const gameCount = gameCounts.reduce((a, c) => a + c); - return convertStoreEmulatorToFrontend(emulator, gameCount, systems); - })); + const execPaths: EmulatorSourceEntryType[] = []; + await plugins.hooks.emulators.findEmulatorSource.promise({ emulator: e.name, sources: execPaths }); + const integrations = findEmulatorPluginIntegration(e.name, execPaths); + + e.gameCount = gameCounts.reduce((a, c) => a + c); + e.integrations = integrations; + })); if (query.missing) { @@ -98,25 +87,31 @@ export const store = new Elysia({ prefix: '/api/store' }) }) .get('/games/featured', async () => { - const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json'); - const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json()); - return Promise.all(games.featured.map(async g => + const games: FrontEndGameTypeDetailed[] = []; + await plugins.hooks.store.fetchFeaturedGames.promise({ games }); + + return Promise.all(games.map(async g => { - const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(`${g.system}@${g.title}`, 'store') }); + const localGame = await db.query.games.findFirst({ where: getLocalGameMatch(g.id.id, g.id.source) }); if (localGame) return convertLocalToFrontendDetailed(localGame); - return convertStoreToFrontendDetailed(g.system, g.title, g); + return g; })); }) .get('/stats', async () => { - const emulatesParsed = await getAllStoreEmulatorPackages(); - const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length; + let frontEndEmulators: FrontEndEmulator[] = []; + await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators }); + const storeEmulatorCount = frontEndEmulators.length; const gameCount = await db.$count(appSchema.games); return { storeEmulatorCount, gameCount }; }) + .get('/media/*', async ({ params }) => + { + return Bun.file(path.join(getStoreFolder(), params["*"])); + }) .get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) => { return Bun.file(path.join(getStoreFolder(), "media", "screenshots", id, name)); @@ -124,49 +119,14 @@ export const store = new Elysia({ prefix: '/api/store' }) { params: z.object({ id: z.string(), name: z.string() }) }) .get('/emulator/:id/update', async ({ params: { id } }) => { - const emulatorPackage = await getStoreEmulatorPackage(id); - const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!); - return downloadInfo; + return plugins.hooks.store.fetchDownload.promise({ id }); }, { response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()]) }) .get('/emulator/:id', async ({ params: { id } }) => { - const emulatorPackage = await getStoreEmulatorPackage(id); - if (!emulatorPackage) return status("Not Found"); - - const systems = await buildStoreFrontendEmulatorSystems(emulatorPackage); - - const execPaths = await findExecsByName(emulatorPackage.name); - - const emulatorScreenshotsPath = path.join(getStoreFolder(), "media", "screenshots", id); - const screenshots = await fs.exists(emulatorScreenshotsPath) ? await fs.readdir(emulatorScreenshotsPath) : []; - const biosDirPath = path.join(config.get('downloadPath'), 'bios', id); - const biosFiles = await fs.exists(biosDirPath) ? await fs.readdir(biosDirPath) : []; - const storeDownloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage); - - const emulator: FrontEndEmulatorDetailed = { - name: emulatorPackage.name, - description: emulatorPackage.description, - systems, - validSources: execPaths, - screenshots: screenshots.map(s => `/api/store/screenshot/emulator/${id}/${s}`), - gameCount: 0, - homepage: emulatorPackage.homepage, - downloads: (await Promise.all(emulatorPackage.downloads?.[`${process.platform}:${process.arch}`].map(async d => - { - const download = await getEmulatorDownload(emulatorPackage, d.type).catch(e => undefined); - return download?.info; - }) ?? [])).filter(d => !!d).map(d => ({ name: d.type, type: d.type, version: d.version })), - logo: emulatorPackage.logo, - biosRequirement: emulatorPackage.bios, - bios: biosFiles, - integrations: findEmulatorPluginIntegration(emulatorPackage.name, execPaths), - storeDownloadInfo: storeDownloadInfo - }; - - return emulator; + return plugins.hooks.store.fetchEmulator.promise({ id }); }, { params: z.object({ id: z.string() }) }) .post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) => { diff --git a/src/bun/api/system.ts b/src/bun/api/system.ts index 720a10b..5292b85 100644 --- a/src/bun/api/system.ts +++ b/src/bun/api/system.ts @@ -2,7 +2,7 @@ import Elysia from "elysia"; import open from 'open'; import z from "zod"; import os from 'node:os'; -import { cachePath, config, events } from "./app"; +import { cachePath, config, events, taskQueue } from "./app"; import { isSteamDeck, openExternal } from "../utils"; import fs from 'node:fs/promises'; import buildNotificationsStream from "./notifications"; @@ -12,6 +12,22 @@ import { getDevices, getDevicesCurated } from "./drives"; import getFolderSize from "get-folder-size"; import si from 'systeminformation'; import { getStoreFolder } from "./store/services/gamesService"; +import ReloadPluginsJob from "./jobs/reload-plugins-job"; +import { semver } from "bun"; +import packageDef from '~/package.json'; + +async function checkUpdate () +{ + const latest = await fetch('https://api.github.com/repos/simeonradivoev/gameflow-deck/releases/latest'); + if (latest.ok) + { + const data = await latest.json(); + const hasUpdate = semver.order(data.tag_name, packageDef.version); + return hasUpdate; + } + + return 0; +} export const system = new Elysia({ prefix: '/api/system' }) .post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) => @@ -60,29 +76,64 @@ export const system = new Elysia({ prefix: '/api/system' }) set.headers["cache-control"] = 'no-cache'; set.headers['connection'] = 'keep-alive'; return new Response(buildNotificationsStream()); + }) + .get('/notifications/all', ({ }) => + { + }) .ws('/info/system', { response: z.discriminatedUnion('type', [ z.object({ type: z.literal('info'), data: SystemInfoSchema }), - z.object({ type: z.literal('focus') }) + z.object({ type: z.literal('focus') }), + z.object({ type: z.literal('loading'), progress: z.number(), state: z.string().optional() }), + z.object({ type: z.literal('loaded') }), ]), async open (ws) { - const battery = await si.battery(); - const wifi = await si.wifiConnections(); - const bluetooth = await si.bluetoothDevices(); - ws.send({ - type: 'info', - data: { - battery: battery, - wifiConnections: wifi, - bluetoothDevices: bluetooth - } - }, true); + const existingLoading = taskQueue.findJob(ReloadPluginsJob.id, ReloadPluginsJob); + if (existingLoading) ws.send({ type: 'loading', progress: existingLoading.progress, state: existingLoading.state }); + else ws.send({ type: 'loaded' }); + + const startInfo = async () => + { + const battery = await si.battery(); + const wifi = await si.wifiConnections(); + const bluetooth = await si.bluetoothDevices(); + ws.send({ + type: 'info', + data: { + battery: battery, + wifiConnections: wifi, + bluetoothDevices: bluetooth + } + }, true); + }; + startInfo(); const handleFocus = () => ws.send({ type: 'focus' }); events.on('focus', handleFocus); - (ws.data as any).dispose = [() => events.removeListener('focus', handleFocus)]; + const dispose: (() => void)[] = []; + + dispose.push(taskQueue.on('progress', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loading", progress: e.progress, state: e.state }); + })); + dispose.push(taskQueue.on('started', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loading", progress: 0 }); + })); + dispose.push(taskQueue.on('ended', e => + { + if (e.id !== ReloadPluginsJob.id) return; + ws.send({ type: "loaded" }); + })); + + (ws.data as any).dispose = [...dispose, () => + { + events.removeListener('focus', handleFocus); + }]; (ws.data as any).observer = setInterval(async () => { const battery = await si.battery(); @@ -209,4 +260,8 @@ export const system = new Elysia({ prefix: '/api/system' }) await openExternal(url); }, { body: z.object({ url: z.string() }) + }) + .get('/update', async () => + { + return checkUpdate(); }); \ No newline at end of file diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 308f217..34459ed 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -1,7 +1,8 @@ +import { and } from 'drizzle-orm'; import EventEmitter from 'node:events'; -import z from 'zod'; +import z, { any } from 'zod'; export class TaskQueue { @@ -121,29 +122,29 @@ export interface EventsList queued: [e: BaseEvent]; } -interface BaseEvent +export interface BaseEvent { id: string; job: IPublicJob; } -interface ErrorEvent extends BaseEvent +export interface ErrorEvent extends BaseEvent { error: unknown; } -interface AbortEvent extends BaseEvent +export interface AbortEvent extends BaseEvent { reason?: any; } -interface ProgressEvent extends BaseEvent +export interface ProgressEvent extends BaseEvent { progress: number; state?: string; } -interface CompletedEvent extends BaseEvent +export interface CompletedEvent extends BaseEvent { } diff --git a/src/bun/types/typesc.schema.ts b/src/bun/types/typesc.schema.ts index aafc779..c87a6ef 100644 --- a/src/bun/types/typesc.schema.ts +++ b/src/bun/types/typesc.schema.ts @@ -1,28 +1,52 @@ import z from "zod"; import { GameflowHooks } from "../api/hooks/app"; +import Conf from "conf"; +import { $ZodRegistry } from "zod/v4/core"; +import EventEmitter from "node:events"; export const PluginContextSchema = z.object({ hooks: z.instanceof(GameflowHooks) }); +export const PluginLoadingContextSchema = z.object({ + setProgress: z.function().input([z.number(), z.string()]).output(z.void()), + config: z.instanceof(Conf), + zodRegistry: z.instanceof($ZodRegistry) +}).extend(PluginContextSchema.shape); + export const PluginDescriptionSchema = z.object({ name: z.string(), displayName: z.string(), version: z.string(), description: z.string(), icon: z.url().optional(), - keywords: z.array(z.string()).optional() + keywords: z.array(z.string()).optional(), + category: z.string().default("other"), + canDisable: z.boolean().default(true).optional() }); export const PluginSchema = z.object({ - setup: z.function().output(z.promise(z.void())).optional(), - load: z.function().input([PluginContextSchema]).output(z.void()), - onBeforeReload: z.function().input([PluginContextSchema]).output(z.void()).optional(), - cleanup: z.function().output(z.promise(z.void())).optional() + load: z.function().input([PluginLoadingContextSchema]).output(z.promise(z.void())), + cleanup: z.function().output(z.promise(z.void())).optional(), + settingsSchema: z.instanceof(z.ZodObject).optional(), + settingsMigrations: z.record(z.string(), z.function().input([z.instanceof(Conf)]).output(z.void())).optional(), + eventsNames: z.object({ + id: z.string(), + title: z.string().optional(), + description: z.string().optional(), + action: z.string() + }).array().optional(), + onEvent: z.function().input([z.string()]).output(z.any()).optional() }); -export type PluginType = z.infer; +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({ diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index 76dcda3..605f15d 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -9,7 +9,6 @@ export const focusQueue: string[] = []; export default function App (data: { children: any; }) { - useEffect(() => { const focusMap = new Map(); diff --git a/src/mainview/components/AppCommunication.tsx b/src/mainview/components/AppCommunication.tsx index f0ba0a3..b30ef41 100644 --- a/src/mainview/components/AppCommunication.tsx +++ b/src/mainview/components/AppCommunication.tsx @@ -1,12 +1,16 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SystemInfoContext } from "../scripts/contexts"; import { systemApi } from "../scripts/clientApi"; import { SystemInfoType } from "@/shared/constants"; +import LoadingScreen from "./LoadingScreen"; export default function AppCommunication (data: { children: any; }) { - const [systemInfo, setSystemInfo] = useState(); + const [loadingInfo, setLoadingInfo] = useState(undefined); + const [loading, setLoading] = useState(true); + const loadingProgressBarRef = useRef(null); + useEffect(() => { const sub = systemApi.api.system.info.system.subscribe(); @@ -20,14 +24,32 @@ export default function AppCommunication (data: { children: any; }) case "focus": window.focus(); break; + case "loading": + setLoadingInfo(data.state); + if (loadingProgressBarRef.current) + loadingProgressBarRef.current.value = data.progress; + setLoading(true); + break; + case "loaded": + setLoading(false); + break; } - }); document.documentElement.dataset.loaded = "true"; }, []); return - {data.children} + {loading ? + +
+
+ + {loadingInfo} +
+ +
+
+ : data.children}
; } \ No newline at end of file diff --git a/src/mainview/components/CardElement.tsx b/src/mainview/components/CardElement.tsx index fdbee68..6960315 100644 --- a/src/mainview/components/CardElement.tsx +++ b/src/mainview/components/CardElement.tsx @@ -4,6 +4,7 @@ import { JSX } from "react"; import { twMerge } from "tailwind-merge"; import useActiveControl from "../scripts/gamepads"; import { oneShot } from "../scripts/audio/audio"; +import ImageWithFallbacks from "./ImageWithFallbacks"; export function GameCardSkeleton () { @@ -21,8 +22,8 @@ export function GameCardSkeleton () export interface GameCardParams extends FocusParams { title: string; - subtitle: string | JSX.Element; - preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element); + subtitle?: string | JSX.Element; + preview?: string | JSX.Element | URL[] | ((p: { focused: boolean; }) => JSX.Element); srcset?: string; focusKey: string; index: number; @@ -49,6 +50,21 @@ export default function CardElement (data: GameCardParams & InteractParams) }); const { isPointer } = useActiveControl(); + let preview: any = undefined; + if (typeof data.preview === "string") + { + preview = ; + } else if (Array.isArray(data.preview)) + { + preview = ; + } else if (typeof data.preview === 'function') + { + preview = data.preview({ focused }); + } else + { + preview = data.preview; + } + return (
  • - {typeof data.preview === "string" ? ( - - ) : ( - typeof data.preview === 'function' ? data.preview({ focused }) : data.preview - )} + {preview}
    diff --git a/src/mainview/components/CardList.tsx b/src/mainview/components/CardList.tsx index 671018f..23ed5e7 100644 --- a/src/mainview/components/CardList.tsx +++ b/src/mainview/components/CardList.tsx @@ -20,9 +20,9 @@ export interface GameMetaExtra extends GameMeta function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams) { let preview: GameCardParams['preview'] = data.game.preview; - if (!preview && data.game.previewUrl) + if (!preview && data.game.previewUrls) { - preview = data.game.previewUrl; + preview = data.game.previewUrls; } const handleAction = (ctx: InteractParamsArgs) => @@ -40,7 +40,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara focusKey={data.game.focusKey} data-index={data.i} title={data.game.title} - subtitle={data.game.subtitle ?? ""} + subtitle={data.game.subtitle} srcset={data.game.previewSrcset} onFocus={(focusKey, node, details) => { @@ -69,8 +69,6 @@ export function CardList (data: { { const { ref, focusKey } = useFocusable({ focusKey: data.id, - forceFocus: true, - autoRestoreFocus: true, focusable: data.games.length > 0, preferredChildFocusKey: data.focus }); diff --git a/src/mainview/components/CollectionList.tsx b/src/mainview/components/CollectionList.tsx index 121be98..c04db7f 100644 --- a/src/mainview/components/CollectionList.tsx +++ b/src/mainview/components/CollectionList.tsx @@ -37,7 +37,6 @@ export default function CollectionList (data: { id: `${g.id.source}@${g.id.id}`, title: g.name, focusKey: `collection-${g.id}`, - subtitle: "", previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`, badges: [ @@ -46,7 +45,7 @@ export default function CollectionList (data: { ], } satisfies GameMetaExtra))} onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect} - onGameFocus={(id, node, details) => + onFocus={(id, node, details) => { data.setBackground( `https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`, diff --git a/src/mainview/components/CollectionsDetail.tsx b/src/mainview/components/CollectionsDetail.tsx index 0b9e0e1..8108149 100644 --- a/src/mainview/components/CollectionsDetail.tsx +++ b/src/mainview/components/CollectionsDetail.tsx @@ -10,7 +10,7 @@ import { GameListFilterSchema, GameListFilterType } from '@/shared/constants'; import { HandleGoBack } from '../scripts/utils'; import LoadingCardList from './LoadingCardList'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { gameQuery } from '../scripts/queries/romm'; +import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm'; import { useNavigate, useRouter } from '@tanstack/react-router'; import SelectMenu from './SelectMenu'; import { RoundButton } from './RoundButton'; @@ -41,7 +41,6 @@ export interface CollectionsDetailParams export function CollectionsDetail (data: CollectionsDetailParams) { const router = useRouter(); - const [filterValues, setFilterValues] = useState(); const queryClient = useQueryClient(); const finalFilter = { ...data.localFilter, ...data.filters }; const focusKey = `game-list-${data.id}`; @@ -50,6 +49,8 @@ export function CollectionsDetail (data: CollectionsDetailParams) preferredChildFocusKey: `${focusKey}-list` }); + const { data: filterValues } = useQuery(gameFiltersQuery({ source: data.filters?.source })); + useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]); const handleScroll: FocusParams['onFocus'] = (cardId, node, details) => @@ -79,7 +80,6 @@ export function CollectionsDetail (data: CollectionsDetailParams) = { store: , local: , romm: +}; + +export const pluginCategoryIcons: Record = { + saves: , + sources: , + launchers: , + emulators: +}; + +export const pluginCategoryPriorities: Record = { + saves: 100, + sources: 90, + launchers: 80, + emulators: 60 }; \ No newline at end of file diff --git a/src/mainview/components/FrontEndGameCard.tsx b/src/mainview/components/FrontEndGameCard.tsx index 39bd6ab..3d8ea25 100644 --- a/src/mainview/components/FrontEndGameCard.tsx +++ b/src/mainview/components/FrontEndGameCard.tsx @@ -13,16 +13,24 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; - const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); - platformUrl.searchParams.set('width', "64"); - const subtitle =
    - {!!data.game.path_platform_cover && } -

    {data.game.platform_display_name}

    -
    ; + let subtitle: any = undefined; + if (data.game.path_platform_cover) + { + const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + subtitle =
    + {!!data.game.path_platform_cover && } +

    {data.game.platform_display_name}

    +
    ; + } - const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`); - previewUrl.searchParams.delete('ts'); - previewUrl.searchParams.set('width', "640"); + const previewUrls = data.game.path_covers.map(c => + { + const url = new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.delete('ts'); + url.searchParams.set('width', "640"); + return url; + }); const badges: JSX.Element[] = []; @@ -53,7 +61,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG badges={badges} onFocus={data.onFocus} onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)} - preview={previewUrl.href} + preview={previewUrls} title={data.game.name ?? ""} subtitle={subtitle} focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)} diff --git a/src/mainview/components/GameList.tsx b/src/mainview/components/GameList.tsx index b94a45d..73caa98 100644 --- a/src/mainview/components/GameList.tsx +++ b/src/mainview/components/GameList.tsx @@ -1,4 +1,4 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { GameMetaExtra, CardList } from "./CardList"; import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants"; import { useNavigate } from "@tanstack/react-router"; @@ -19,7 +19,6 @@ export interface GameListParams extends FocusParams className?: string; finalElement?: JSX.Element; saveChildFocus?: "session" | "local"; - setFilterValues?: (filters: FrontEndFilterLists) => void; } export function GameList (data: GameListParams) @@ -37,7 +36,7 @@ export function GameList (data: GameListParams) try { const screenshotUrl = game.paths_screenshots && game.paths_screenshots.length > 0 ? new URL(`${RPC_URL(__HOST__)}${game.paths_screenshots[new Date().getMinutes() % game.paths_screenshots.length]}`) : undefined; - const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_cover}`); + const coverUrl = new URL(`${RPC_URL(__HOST__)}${game.path_covers[0]}`); const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl); previewUrl.searchParams.delete('ts'); data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href); @@ -48,11 +47,6 @@ export function GameList (data: GameListParams) } }; - useEffect(() => - { - data.setFilterValues?.(games.data.filters); - }, [games.data.filters]); - function handleDefaultSelect (g: FrontEndGameType) { navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } }); @@ -79,23 +73,31 @@ export function GameList (data: GameListParams) badges.push(); } - const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); - previewUrl.searchParams.delete('ts'); + const previewUrls = g.path_covers.map(c => + { + const url = new URL(`${RPC_URL(__HOST__)}${c}`); + url.searchParams.delete('ts'); + return url; + }); - const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); - platformUrl.searchParams.set('width', "64"); + let platformUrl: URL | undefined = undefined; + if (g.path_platform_cover) + { + platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`); + platformUrl.searchParams.set('width', "64"); + } return { id: `${g.id.source}@${g.id.id}`, - focusKey: g.slug ?? `game-${g.id}`, + focusKey: `${data.id}-${g.id.source}@${g.id.id}`, title: g.name ?? "", subtitle: (
    - {!!g.path_platform_cover && } +

    {g.platform_display_name}

    ), - previewUrl: previewUrl.href, + previewUrls: previewUrls, badges: badges, onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g), onFocus: () => handleFocus(g.id, g.source, g.source_id) diff --git a/src/mainview/components/Header.tsx b/src/mainview/components/Header.tsx index 3a2e8dd..803e9c7 100644 --- a/src/mainview/components/Header.tsx +++ b/src/mainview/components/Header.tsx @@ -13,6 +13,7 @@ import BatteryWarning, Bell, Bluetooth, + CircleFadingArrowUp, Clock, Settings, Wifi, @@ -31,6 +32,7 @@ import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; import { SystemInfoContext } from "../scripts/contexts"; import { useRouter } from "@tanstack/react-router"; import { oneShot } from "../scripts/audio/audio"; +import { hasUpdateQuery } from "../scripts/queries/system"; function HeaderAvatar (data: { id: string; @@ -83,6 +85,14 @@ export interface HeaderAccount action?: () => void; } +function UpdateStatus () +{ + const hasUnread = false; + return
    + +
    ; +} + function NotificationStatus () { const hasUnread = false; @@ -249,13 +259,15 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); + const { data: hasUpdate } = useQuery(hasUpdateQuery); return
    -
    +
    + {!!hasUpdate && hasUpdate >= 1 && }
    {!!data.buttons &&
    } diff --git a/src/mainview/components/ImageWithFallbacks.tsx b/src/mainview/components/ImageWithFallbacks.tsx new file mode 100644 index 0000000..7954da3 --- /dev/null +++ b/src/mainview/components/ImageWithFallbacks.tsx @@ -0,0 +1,19 @@ +export default function ImageWithFallbacks (data: { + src: URL[]; + draggable?: boolean; + className?: string; +}) +{ + const handleError = (e: React.SyntheticEvent) => + { + const img = e.currentTarget; + const nextIndex = Number(img.dataset.index) + 1; + + if (nextIndex < data.src.length) + { + img.dataset.index = String(nextIndex); + img.src = data.src[nextIndex].href; + } + }; + return ; +} \ No newline at end of file diff --git a/src/mainview/components/LoadingCardList.tsx b/src/mainview/components/LoadingCardList.tsx index e25dc26..d811d55 100644 --- a/src/mainview/components/LoadingCardList.tsx +++ b/src/mainview/components/LoadingCardList.tsx @@ -17,7 +17,6 @@ export default function LoadingCardList (data: { id: string, placeholderCount: n ref={ref} title="Games" id={`card-list-placeholder`} - save-child-focus="session" className={twMerge("items-center justify-center-safe h-full", data.grid ? "grid h-fit sm:gap-2 md:gap-5 auto-rows-min grid-cols-[repeat(auto-fill,var(--game-card-width))]" : 'landscape:grid landscape:grid-flow-col landscape:auto-cols-min auto-rows-[1fr] sm:gap-2 md:gap-4 portrait:grid portrait:auto-rows-min portrait:grid-cols-[repeat(auto-fill,var(--game-card-width))] *:portrait:aspect-8/10 *:landscape:aspect-8/12 sm:landscape:max-h-84 md:max-h-128!', diff --git a/src/mainview/components/LoadingScreen.tsx b/src/mainview/components/LoadingScreen.tsx new file mode 100644 index 0000000..f92cc98 --- /dev/null +++ b/src/mainview/components/LoadingScreen.tsx @@ -0,0 +1,9 @@ +export default function LoadingScreen (data: { children?: any; }) +{ + return
    +
    +
    +
    + {data.children} +
    ; +} \ No newline at end of file diff --git a/src/mainview/components/PlatformsList.tsx b/src/mainview/components/PlatformsList.tsx index 039e20a..2efcfbd 100644 --- a/src/mainview/components/PlatformsList.tsx +++ b/src/mainview/components/PlatformsList.tsx @@ -3,10 +3,36 @@ import { useNavigate } from "@tanstack/react-router"; import { DefaultRommStaleTime, RPC_URL } from "@shared/constants"; import { CardList, GameMetaExtra } from "./CardList"; import { rommApi } from "../scripts/clientApi"; -import { JSX, useMemo } from "react"; -import { HardDrive } from "lucide-react"; +import { JSX, useMemo, useState } from "react"; +import { Gamepad2, HardDrive } from "lucide-react"; import { mobileCheck } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; +import placeholder from '../assets/256x256.png?url'; + +function Preview (data: { index: number, pathCover: string | null; }) +{ + const coverUrl = new URL(`${RPC_URL(__HOST__)}${data.pathCover}`); + coverUrl.searchParams.set('width', "320"); + const isMobile = mobileCheck(); + return
    + e.currentTarget.src = placeholder} + src={coverUrl.href} + > + +
    ; +} export function PlatformsList (data: { id: string, @@ -17,7 +43,7 @@ export function PlatformsList (data: { saveChildFocus?: "session" | "local"; } & FocusParams) { - const isMobile = mobileCheck(); + const navigate = useNavigate(); const { data: platforms } = useSuspenseQuery( { @@ -44,37 +70,19 @@ export function PlatformsList (data: { badges.push({g.game_count}); if (g.hasLocal) badges.push(); - const coverUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`); - coverUrl.searchParams.set('width', "320"); + const entry: GameMetaExtra = { id: g.slug, focusKey: g.slug, title: g.name, - subtitle: g.family_name ?? "", - previewUrl: "", + subtitle: g.family_name ?? undefined, + previewUrls: "", badges, onFocus: () => data.setBackground( g.paths_screenshots.length > 0 ? `${RPC_URL(__HOST__)}${g.paths_screenshots[new Date().getMinutes() % g.paths_screenshots.length]}` : `${RPC_URL(__HOST__)}/api/romm/image?url=https://picsum.photos/id/${10 + i}/1280/720.webp`, ), onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id), - preview: - () =>
    - -
    - , + preview: () => }; return entry; }), [platforms]); diff --git a/src/mainview/components/SelectMenu.tsx b/src/mainview/components/SelectMenu.tsx index 4ad4878..4caf3a6 100644 --- a/src/mainview/components/SelectMenu.tsx +++ b/src/mainview/components/SelectMenu.tsx @@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router"; import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation"; -import { DoorOpen, Gamepad2, RefreshCcw, Settings, Store } from "lucide-react"; +import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react"; import { systemApi } from "../scripts/clientApi"; import { FOCUS_KEYS } from "../scripts/types"; @@ -54,12 +54,24 @@ export default function SelectMenu (data: { rootFocusKey: string; }) action (ctx) { setOpen(false); - navigate({ to: "/settings/accounts" }); + navigate({ to: "/settings/interface" }); }, - selected: !!matchRoute({ to: '/settings/accounts' }), + selected: !!matchRoute({ to: '/settings' }) && !matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }), type: "accent", id: "settings-m" }, + { + content: "Plugins", + icon: , + action (ctx) + { + setOpen(false); + navigate({ to: "/settings/plugins" }); + }, + selected: !!matchRoute({ to: '/settings/plugins' }) && !matchRoute({ to: '/settings/plugin/$source' }), + type: "accent", + id: "plugins-m" + }, { content: "Reload", icon: , diff --git a/src/mainview/components/game/ActionButton.tsx b/src/mainview/components/game/ActionButton.tsx index e950a67..7a6db5e 100644 --- a/src/mainview/components/game/ActionButton.tsx +++ b/src/mainview/components/game/ActionButton.tsx @@ -12,7 +12,7 @@ export default function ActionButton (data: { square?: boolean, onFocus?: () => void; tooltip?: string, - tooltip_type?: 'accent' | 'error'; + tooltipType?: 'accent' | 'error'; disabled?: boolean; } & InteractParams) { @@ -30,7 +30,7 @@ export default function ActionButton (data: { ref={ref} onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })} data-tooltip={data.tooltip} - data-tooltip-type={data.tooltip_type} + data-tooltip-type={data.tooltipType} className={twMerge("header-icon flex flex-col gap-2 md:px-5 md:py-4 rounded-3xl md:text-2xl justify-center items-center cursor-pointer disabled:opacity-30 active:bg-base-100 active:transition-none active:text-base-content", "hover:ring-7 hover:ring-primary", styles[data.type], classNames({ "rounded-full sm:size-14 md:size-21 hover:bg-base-content hover:text-base-300 hover:ring-7 hover:ring-primary": !data.square }), data.className)}> {data.icon} diff --git a/src/mainview/components/game/ActionButtons.tsx b/src/mainview/components/game/ActionButtons.tsx index 8730779..446de01 100644 --- a/src/mainview/components/game/ActionButtons.tsx +++ b/src/mainview/components/game/ActionButtons.tsx @@ -1,10 +1,10 @@ -import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, validateSourceQuery } from "@/mainview/scripts/queries/romm"; +import { deleteGameMutation, fixSourceMutation, gameInvalidationQuery, updateSourceMutation, validateSourceQuery } from "@/mainview/scripts/queries/romm"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog"; import { getErrorMessage } from "react-error-boundary"; import toast from "react-hot-toast"; -import { Hammer, Settings, Trash, Trophy } from "lucide-react"; +import { Hammer, RefreshCcw, Settings, Trash, Trophy } from "lucide-react"; import MainActions from "./MainActions"; import ActionButton from "./ActionButton"; import { useLocalStorage } from "usehooks-ts"; @@ -34,7 +34,8 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots'); const fixMutation = useMutation({ - ...fixSourceMutation, onSuccess (data, variables, onMutateResult, context) + ...fixSourceMutation, + onSuccess (data, variables, onMutateResult, context) { if (onMutateResult) toast.success("Updated Source"); context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)).then(() => router.history.back()); @@ -44,6 +45,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, toast.error(getErrorMessage(error) ?? "Error While Trying To Fix"); } }); + const updateMutation = useMutation({ + ...updateSourceMutation, + onSuccess (data, variables, onMutateResult, context) + { + if (onMutateResult) toast.success("Updated Source"); + context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)); + }, + onError (error) + { + toast.error(getErrorMessage(error) ?? "Error While Trying To Update"); + } + }); const { data: validation } = useQuery(validateSourceQuery(data.source, data.id)); const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' }); const router = useRouter(); @@ -62,7 +75,7 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, useBlocker({ shouldBlockFn: () => { - return deleteMutation.isPending || fixMutation.isPending; + return deleteMutation.isPending || fixMutation.isPending || updateMutation.isPending; } }); @@ -85,15 +98,34 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, { contextOptions.push({ id: "fix_source", - action (ctx) + async action (ctx) { - if (data.game) - fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }); + if (!data.game) return; + await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id }); + ctx.close(); + router.navigate({ replace: true }); }, icon: fixMutation.isPending ? : , content: "Try Fix Source", type: "warning" }); + } else if (data.game?.id.source === 'local') + { + contextOptions.push({ + id: 'update_source', + async action (ctx) + { + if (data.game) + { + await updateMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id }); + ctx.close(); + router.navigate({ replace: true }); + } + }, + icon: updateMutation.isPending ? : , + content: "Update Metadata", + type: "primary" + }); } const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: , canClose: !deleteMutation.isPending }); diff --git a/src/mainview/components/game/Details.tsx b/src/mainview/components/game/Details.tsx index c4166a5..ce7add0 100644 --- a/src/mainview/components/game/Details.tsx +++ b/src/mainview/components/game/Details.tsx @@ -40,7 +40,7 @@ export default function Details (data: { const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined; if (platformCoverImg) platformCoverImg.searchParams.set("width", "64"); - const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined; + const gameCoverImg = data.game?.path_covers ? `${RPC_URL(__HOST__)}${data.game?.path_covers[0]}` : undefined; let fileSizeIcon: JSX.Element | undefined; if (!data.game) diff --git a/src/mainview/components/game/MainActions.tsx b/src/mainview/components/game/MainActions.tsx index d4067ee..681afd2 100644 --- a/src/mainview/components/game/MainActions.tsx +++ b/src/mainview/components/game/MainActions.tsx @@ -69,7 +69,6 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { const errorMessage = getErrorMessage(e.data.error); if (!errorMessage) return; - toast.error(errorMessage); setError(errorMessage); } }); @@ -137,7 +136,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so mainButton = { @@ -169,7 +168,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so { case 'present': case 'install': - installMut.mutate(); + installMut.mutate({}); break; } }} diff --git a/src/mainview/components/options/OptionInput.tsx b/src/mainview/components/options/OptionInput.tsx index 31c1d27..08a6261 100644 --- a/src/mainview/components/options/OptionInput.tsx +++ b/src/mainview/components/options/OptionInput.tsx @@ -19,6 +19,7 @@ export function OptionInput (data: { step?: number; defaultValue?: string | boolean | number; autocomplete?: HTMLInputAutoCompleteAttribute; + compact?: boolean; onBlur?: FocusEventHandler; onChange?: (value: string | number | boolean) => void; }) @@ -121,7 +122,7 @@ export function OptionInput (data: { }; return ( -