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
This commit is contained in:
parent
444d8c4c27
commit
c09fbd3dc8
115 changed files with 4139 additions and 1502 deletions
27
bun.lock
27
bun.lock
|
|
@ -10,6 +10,7 @@
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
"@elysiajs/eden": "^1.4.6",
|
"@elysiajs/eden": "^1.4.6",
|
||||||
"@jimp/wasm-webp": "^1.6.0",
|
"@jimp/wasm-webp": "^1.6.0",
|
||||||
|
"@phalcode/ts-igdb-client": "^1.0.26",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"conf": "^15.0.2",
|
"conf": "^15.0.2",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"node-unrar-js": "^2.0.2",
|
"node-unrar-js": "^2.0.2",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
|
"p-queue": "^9.1.2",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"slugify": "^1.6.9",
|
"slugify": "^1.6.9",
|
||||||
"smol-toml": "^1.6.1",
|
"smol-toml": "^1.6.1",
|
||||||
|
|
@ -32,7 +34,6 @@
|
||||||
"tapable": "^2.3.0",
|
"tapable": "^2.3.0",
|
||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
"tough-cookie-file-store": "^3.3.0",
|
"tough-cookie-file-store": "^3.3.0",
|
||||||
"ts-igdb-client": "^0.4.2",
|
|
||||||
"unzip-stream": "^0.3.4",
|
"unzip-stream": "^0.3.4",
|
||||||
"webview-bun": "^2.4.0",
|
"webview-bun": "^2.4.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
|
|
@ -58,8 +59,10 @@
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
"@types/ini": "^4.1.1",
|
"@types/ini": "^4.1.1",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
"@types/mustache": "^4.2.6",
|
"@types/mustache": "^4.2.6",
|
||||||
"@types/node-7z": "^2.1.11",
|
"@types/node-7z": "^2.1.11",
|
||||||
|
"@types/rclone.js": "^0.6.3",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@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=="],
|
"@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=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
|
"@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/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": ["@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
@ -1868,6 +1877,8 @@
|
||||||
|
|
||||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
"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=="],
|
"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=="],
|
"load-json-file/pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="],
|
||||||
|
|
|
||||||
31
drizzle/0002_flowery_rocket_raccoon.sql
Normal file
31
drizzle/0002_flowery_rocket_raccoon.sql
Normal file
|
|
@ -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`);
|
||||||
479
drizzle/meta/0002_snapshot.json
Normal file
479
drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,13 @@
|
||||||
"when": 1772998956867,
|
"when": 1772998956867,
|
||||||
"tag": "0001_outstanding_silk_fever",
|
"tag": "0001_outstanding_silk_fever",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1776111721964,
|
||||||
|
"tag": "0002_flowery_rocket_raccoon",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
"@elysiajs/cors": "^1.4.1",
|
"@elysiajs/cors": "^1.4.1",
|
||||||
"@elysiajs/eden": "^1.4.6",
|
"@elysiajs/eden": "^1.4.6",
|
||||||
"@jimp/wasm-webp": "^1.6.0",
|
"@jimp/wasm-webp": "^1.6.0",
|
||||||
|
"@phalcode/ts-igdb-client": "^1.0.26",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"conf": "^15.0.2",
|
"conf": "^15.0.2",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
|
@ -65,6 +66,7 @@
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"node-unrar-js": "^2.0.2",
|
"node-unrar-js": "^2.0.2",
|
||||||
"open": "^11.0.0",
|
"open": "^11.0.0",
|
||||||
|
"p-queue": "^9.1.2",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"slugify": "^1.6.9",
|
"slugify": "^1.6.9",
|
||||||
"smol-toml": "^1.6.1",
|
"smol-toml": "^1.6.1",
|
||||||
|
|
@ -72,7 +74,6 @@
|
||||||
"tapable": "^2.3.0",
|
"tapable": "^2.3.0",
|
||||||
"tough-cookie": "^6.0.0",
|
"tough-cookie": "^6.0.0",
|
||||||
"tough-cookie-file-store": "^3.3.0",
|
"tough-cookie-file-store": "^3.3.0",
|
||||||
"ts-igdb-client": "^0.4.2",
|
|
||||||
"unzip-stream": "^0.3.4",
|
"unzip-stream": "^0.3.4",
|
||||||
"webview-bun": "^2.4.0",
|
"webview-bun": "^2.4.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|
@ -98,8 +99,10 @@
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
"@types/ini": "^4.1.1",
|
"@types/ini": "^4.1.1",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
"@types/mustache": "^4.2.6",
|
"@types/mustache": "^4.2.6",
|
||||||
"@types/node-7z": "^2.1.11",
|
"@types/node-7z": "^2.1.11",
|
||||||
|
"@types/rclone.js": "^0.6.3",
|
||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/unzip-stream": "^0.3.4",
|
"@types/unzip-stream": "^0.3.4",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ function spawnServer ()
|
||||||
HEADLESS: "true",
|
HEADLESS: "true",
|
||||||
},
|
},
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "inherit",
|
stderr: "pipe",
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
killSignal: 'SIGUSR1',
|
killSignal: 'SIGUSR1',
|
||||||
|
|
@ -40,6 +40,11 @@ function spawnServer ()
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const rle = createInterface({ input: Readable.fromWeb(s.stderr as any) });
|
||||||
|
rle.on('line', e =>
|
||||||
|
{
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
34
scripts/drizzle/es-de/0000_sparkling_banshee.sql
Normal file
34
scripts/drizzle/es-de/0000_sparkling_banshee.sql
Normal file
|
|
@ -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`);
|
||||||
234
scripts/drizzle/es-de/meta/0000_snapshot.json
Normal file
234
scripts/drizzle/es-de/meta/0000_snapshot.json
Normal file
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
scripts/drizzle/es-de/meta/_journal.json
Normal file
13
scripts/drizzle/es-de/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1776039605377,
|
||||||
|
"tag": "0000_sparkling_banshee",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -96,12 +96,18 @@ await Promise.all(platforms.map(async ([platform, arch]) =>
|
||||||
});
|
});
|
||||||
|
|
||||||
const rommMapping = rommPlatforms.data?.find(p =>
|
const rommMapping = rommPlatforms.data?.find(p =>
|
||||||
p.slug === (customMappings as any)[name] ||
|
{
|
||||||
|
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.slug === name ||
|
||||||
p.igdb_slug === name ||
|
p.igdb_slug === name ||
|
||||||
p.hltb_slug === name ||
|
p.display_name === fullname;
|
||||||
p.moby_slug === name ||
|
}
|
||||||
p.display_name === fullname
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const mappings: {
|
const mappings: {
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ import EventEmitter from "node:events";
|
||||||
import { appPath } from "../utils";
|
import { appPath } from "../utils";
|
||||||
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
import { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
import UpdateStoreJob from "./jobs/update-store";
|
|
||||||
import { getStoreFolder } from "./store/services/gamesService";
|
import { getStoreFolder } from "./store/services/gamesService";
|
||||||
import { PluginManager } from "./plugins/plugin-manager";
|
import { PluginManager } from "./plugins/plugin-manager";
|
||||||
import registerPlugins from "./plugins/register-plugins";
|
import registerPlugins from "./plugins/register-plugins";
|
||||||
import controls from './controls/controls';
|
import controls from './controls/controls';
|
||||||
import { RunAPIServer } from "./rpc";
|
import { RunAPIServer } from "./rpc";
|
||||||
import { RunBunServer } from "../server";
|
import { RunBunServer } from "../server";
|
||||||
|
import ReloadPluginsJob from "./jobs/reload-plugins-job";
|
||||||
|
|
||||||
export let config: Conf<SettingsType>;
|
export let config: Conf<SettingsType>;
|
||||||
export let customEmulators: Conf<Record<string, string>>;
|
export let customEmulators: Conf<Record<string, string>>;
|
||||||
|
|
@ -72,7 +72,6 @@ export async function load ()
|
||||||
console.log("Config Path Located At: ", config.path);
|
console.log("Config Path Located At: ", config.path);
|
||||||
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
console.log("Custom Emulator Paths Located At: ", customEmulators.path);
|
||||||
console.log("App Directory is ", process.env.APPDIR);
|
console.log("App Directory is ", process.env.APPDIR);
|
||||||
console.log("Store Directory is ", getStoreFolder());
|
|
||||||
|
|
||||||
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
cachePath = path.join(os.tmpdir(), 'gameflow', 'cache.sqlite');
|
||||||
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
fileCookieStore = new FileCookieStore(path.join(path.dirname(config.path), 'cookies.json'));
|
||||||
|
|
@ -84,14 +83,14 @@ export async function load ()
|
||||||
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
emulatorsDb = drizzle(emulatorsSqlite, { schema: emulatorSchema });
|
||||||
await reloadDatabase();
|
await reloadDatabase();
|
||||||
plugins = new PluginManager();
|
plugins = new PluginManager();
|
||||||
await registerPlugins(plugins);
|
|
||||||
api = await RunAPIServer();
|
api = await RunAPIServer();
|
||||||
|
await registerPlugins(plugins);
|
||||||
|
taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
|
||||||
controlsHandle = await controls();
|
controlsHandle = await controls();
|
||||||
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
|
if (!process.env.PUBLIC_ACCESS) bunServer = await RunBunServer();
|
||||||
|
|
||||||
config.onDidChange('downloadPath', () => reloadDatabase());
|
config.onDidChange('downloadPath', () => reloadDatabase());
|
||||||
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
config.onDidChange('rommAddress', v => client.setConfig({ baseUrl: v }));
|
||||||
taskQueue.enqueue(UpdateStoreJob.id, new UpdateStoreJob());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanup ()
|
export async function cleanup ()
|
||||||
|
|
@ -120,6 +119,7 @@ export async function reloadDatabase ()
|
||||||
db = drizzle(sqlite, { schema });
|
db = drizzle(sqlite, { schema });
|
||||||
cache = drizzle(cacheSqlite, { schema: cacheSchema });
|
cache = drizzle(cacheSqlite, { schema: cacheSchema });
|
||||||
migrate(db!, { migrationsFolder: appPath("./drizzle") });
|
migrate(db!, { migrationsFolder: appPath("./drizzle") });
|
||||||
|
sqlite.run("PRAGMA foreign_keys = ON;");
|
||||||
await cache.run(`
|
await cache.run(`
|
||||||
CREATE TABLE IF NOT EXISTS item_cache (
|
CREATE TABLE IF NOT EXISTS item_cache (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,13 @@ export default new Elysia({ prefix: '/emulatorjs' })
|
||||||
const localGame = await getLocalGame(source, id);
|
const localGame = await getLocalGame(source, id);
|
||||||
if (!localGame) return status("Not Found");
|
if (!localGame) return status("Not Found");
|
||||||
|
|
||||||
const changedSaveFiles: SaveFileChange[] = [];
|
const changedSaveFiles: Record<string, SaveFileChange> = {};
|
||||||
if (save)
|
if (save)
|
||||||
{
|
{
|
||||||
const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS");
|
const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS");
|
||||||
const saveFile = path.join(savesPath, save.name);
|
const saveFile = path.join(savesPath, save.name);
|
||||||
await Bun.write(saveFile, save);
|
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" });
|
events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" });
|
||||||
}
|
}
|
||||||
await updateLocalLastPlayed(localGame.id);
|
await updateLocalLastPlayed(localGame.id);
|
||||||
|
|
@ -85,7 +85,7 @@ export default new Elysia({ prefix: '/emulatorjs' })
|
||||||
id,
|
id,
|
||||||
saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"),
|
saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"),
|
||||||
gameInfo: { platformSlug: localGame?.platform.slug },
|
gameInfo: { platformSlug: localGame?.platform.slug },
|
||||||
changedSaveFiles: changedSaveFiles,
|
changedSaveFiles: [],
|
||||||
validChangedSaveFiles: changedSaveFiles,
|
validChangedSaveFiles: changedSaveFiles,
|
||||||
command: {
|
command: {
|
||||||
id: "EMULATORJS",
|
id: "EMULATORJS",
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, db, emulatorsDb, plugins, taskQueue } from "../app";
|
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 z from "zod";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
import { GameListFilterSchema, SERVER_URL } from "@shared/constants";
|
||||||
import { InstallJob } from "../jobs/install-job";
|
import { InstallJob } from "../jobs/install-job";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { convertLocalToFrontend, convertStoreToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
import { convertLocalToFrontend, getLocalGameMatch, getSourceGameDetailed } from "./services/utils";
|
||||||
import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, validateGameSource } from "./services/statusService";
|
import buildStatusResponse, { fixSource, getValidLaunchCommandsForGame, update, validateGameSource } from "./services/statusService";
|
||||||
import { errorToResponse } from "elysia/adapter/bun/handler";
|
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 { getErrorMessage, SeededRandom } from "@/bun/utils";
|
||||||
import { defaultFormats, defaultPlugins } from 'jimp';
|
import { defaultFormats, defaultPlugins } from 'jimp';
|
||||||
import { createJimp } from "@jimp/core";
|
import { createJimp } from "@jimp/core";
|
||||||
import webp from "@jimp/wasm-webp";
|
import webp from "@jimp/wasm-webp";
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
import { buildStoreFrontendEmulatorSystems, getShuffledStoreGames, getStoreEmulatorPackage, getStoreGameFromPath, getStoreGameManifest } from "../store/services/gamesService";
|
import { buildStoreFrontendEmulatorSystems, getStoreEmulatorPackage } from "../store/services/gamesService";
|
||||||
import { convertStoreEmulatorToFrontend } from "../store/services/emulatorsService";
|
|
||||||
import { host } from "@/bun/utils/host";
|
import { host } from "@/bun/utils/host";
|
||||||
import { LaunchGameJob } from "../jobs/launch-game-job";
|
import { LaunchGameJob } from "../jobs/launch-game-job";
|
||||||
import { cores } from "../emulatorjs/emulatorjs";
|
import { cores } from "../emulatorjs/emulatorjs";
|
||||||
|
import { findEmulatorPluginIntegration } from "../store/services/emulatorsService";
|
||||||
|
|
||||||
// A custom jimp that supports webp
|
// A custom jimp that supports webp
|
||||||
const Jimp = createJimp({
|
const Jimp = createJimp({
|
||||||
|
|
@ -58,8 +58,15 @@ async function processImage (img: string | Buffer | ArrayBuffer, { blur, width,
|
||||||
|
|
||||||
if (typeof img === 'string')
|
if (typeof img === 'string')
|
||||||
{
|
{
|
||||||
const rommFetch = await fetch(img);
|
const res = await fetch(img);
|
||||||
return rommFetch;
|
|
||||||
|
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;
|
return img;
|
||||||
|
|
@ -135,50 +142,7 @@ export default new Elysia()
|
||||||
.get('/games', async ({ query, set }) =>
|
.get('/games', async ({ query, set }) =>
|
||||||
{
|
{
|
||||||
const games: FrontEndGameType[] = [];
|
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 shuffledGames = await getShuffledStoreGames();
|
|
||||||
set.headers['x-max-items'] = shuffledGames.length;
|
|
||||||
const storeGames = await Promise.all(shuffledGames.filter(g =>
|
|
||||||
{
|
|
||||||
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) =>
|
|
||||||
{
|
|
||||||
const system = path.dirname(e.path);
|
|
||||||
const id = path.basename(e.path, path.extname(e.path));
|
|
||||||
|
|
||||||
const localGame = await db.select({
|
|
||||||
...getTableColumns(schema.games),
|
|
||||||
platform: schema.platforms,
|
|
||||||
screenshotIds: sql<number[]>`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[] = [];
|
const where: any[] = [];
|
||||||
let localGamesSet: Set<string> | undefined;
|
let localGamesSet: Set<string> | undefined;
|
||||||
|
|
||||||
|
|
@ -191,7 +155,7 @@ export default new Elysia()
|
||||||
}
|
}
|
||||||
else if (query.platform_id && query.platform_source)
|
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) });
|
const platform = await plugins.hooks.games.platformLookup.promise({ source: query.platform_source, id: query.platform_id ? String(query.platform_id) : undefined });
|
||||||
if (platform)
|
if (platform)
|
||||||
{
|
{
|
||||||
where.push(eq(schema.platforms.slug, platform?.slug));
|
where.push(eq(schema.platforms.slug, platform?.slug));
|
||||||
|
|
@ -208,6 +172,27 @@ export default new Elysia()
|
||||||
where.push(eq(schema.games.source, 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({
|
const localGames = await db.select({
|
||||||
...getTableColumns(schema.games),
|
...getTableColumns(schema.games),
|
||||||
platform: schema.platforms,
|
platform: schema.platforms,
|
||||||
|
|
@ -217,6 +202,7 @@ export default new Elysia()
|
||||||
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
.leftJoin(schema.platforms, eq(schema.platforms.id, schema.games.platform_id))
|
||||||
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
.leftJoin(schema.screenshots, eq(schema.screenshots.game_id, schema.games.id))
|
||||||
.groupBy(schema.games.id)
|
.groupBy(schema.games.id)
|
||||||
|
.orderBy(...ordering)
|
||||||
.where(and(...where));
|
.where(and(...where));
|
||||||
|
|
||||||
localGamesSet = new Set(
|
localGamesSet = new Set(
|
||||||
|
|
@ -236,7 +222,7 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
// Collections are just a remote thing for now.
|
// Collections are just a remote thing for now.
|
||||||
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
||||||
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e));
|
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
|
||||||
games.push(...remoteGames.map(g =>
|
games.push(...remoteGames.map(g =>
|
||||||
{
|
{
|
||||||
if (localGameExistsPredicate(g))
|
if (localGameExistsPredicate(g))
|
||||||
|
|
@ -251,7 +237,7 @@ export default new Elysia()
|
||||||
|
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).filter(g =>
|
games.push(...localGames.slice(query.offset, query.limit !== undefined ? ((query.offset ?? 0) + query.limit) : undefined).filter(g =>
|
||||||
{
|
{
|
||||||
if (query.genres && query.genres.length > 0)
|
if (query.genres && query.genres.length > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -270,7 +256,7 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
const remoteGames: FrontEndGameTypeWithIds[] = [];
|
||||||
const remoteGameSet = new Set<string>();
|
const remoteGameSet = new Set<string>();
|
||||||
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames, filters: filterSets }).catch(e => console.error(e));
|
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
|
||||||
games.push(...remoteGames.filter(g =>
|
games.push(...remoteGames.filter(g =>
|
||||||
{
|
{
|
||||||
if (localGameExistsPredicate(g))
|
if (localGameExistsPredicate(g))
|
||||||
|
|
@ -294,31 +280,6 @@ export default new Elysia()
|
||||||
|
|
||||||
return true;
|
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),
|
age_ratings: Array.from(filterSets.age_ratings),
|
||||||
player_counts: Array.from(filterSets.player_counts),
|
player_counts: Array.from(filterSets.player_counts),
|
||||||
languages: Array.from(filterSets.languages),
|
languages: Array.from(filterSets.languages),
|
||||||
|
|
@ -350,34 +341,21 @@ export default new Elysia()
|
||||||
genres: Array.from(filterSets.genres)
|
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 } }) =>
|
.get('/rom/:source/:id', async ({ params: { id, source } }) =>
|
||||||
{
|
{
|
||||||
const localGame = await db.query.games.findFirst({
|
const filePaths = await plugins.hooks.games.fetchRomFiles.promise({ source, id });
|
||||||
where: getLocalGameMatch(id, source),
|
|
||||||
columns: { path_fs: true },
|
|
||||||
with: { platform: { columns: { es_slug: true } } }
|
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
return Bun.file(filePaths[0]);
|
||||||
const path_fs = path.join(downloadPath, localGame.path_fs);
|
|
||||||
|
|
||||||
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() })
|
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')) });
|
const systemMapping = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, sourceData.platform_slug), eq(emulatorSchema.systemMappings.source, 'romm')) });
|
||||||
if (systemMapping)
|
if (systemMapping)
|
||||||
{
|
{
|
||||||
const emulatorNames = await getEmulatorsForSystem(systemMapping.system);
|
const emulatorNames: string[] = [];
|
||||||
const emulators = await Promise.all(emulatorNames.map(n => getStoreEmulatorPackage(n).then(e => ({ name: n, data: e }))));
|
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)
|
if (name === 'EMULATORJS')
|
||||||
{
|
|
||||||
const systems = await buildStoreFrontendEmulatorSystems(data);
|
|
||||||
return { ...await convertStoreEmulatorToFrontend(data, 0, systems), store_exists: true };
|
|
||||||
}
|
|
||||||
else if (name === 'EMULATORJS')
|
|
||||||
{
|
{
|
||||||
return {
|
return {
|
||||||
name: 'EMULATORJS',
|
name: 'EMULATORJS',
|
||||||
|
|
@ -424,22 +397,34 @@ export default new Elysia()
|
||||||
return system;
|
return system;
|
||||||
})),
|
})),
|
||||||
gameCount: 0,
|
gameCount: 0,
|
||||||
integrations: []
|
source: 'local',
|
||||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
name: name,
|
|
||||||
logo: "",
|
|
||||||
systems: [],
|
|
||||||
gameCount: 0,
|
|
||||||
validSources: [],
|
|
||||||
integrations: []
|
integrations: []
|
||||||
} satisfies FrontEndGameTypeDetailedEmulator;
|
} 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() }),
|
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))
|
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
|
} else
|
||||||
{
|
{
|
||||||
return status('Not Implemented');
|
return status('Not Implemented');
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.string(), source: z.string() }),
|
params: z.object({ id: z.string(), source: z.string() }),
|
||||||
|
query: z.object({ downloadId: z.string().optional() }),
|
||||||
response: z.any()
|
response: z.any()
|
||||||
})
|
})
|
||||||
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
.delete('/game/:source/:id/install', async ({ params: { id, source } }) =>
|
||||||
|
|
@ -501,6 +487,10 @@ export default new Elysia()
|
||||||
{
|
{
|
||||||
return fixSource(source, id);
|
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 }) =>
|
.post('/game/:source/:id/play', async ({ params: { id, source }, body, set }) =>
|
||||||
{
|
{
|
||||||
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
const validCommands = await getValidLaunchCommandsForGame(source, id);
|
||||||
|
|
@ -559,8 +549,6 @@ export default new Elysia()
|
||||||
const emulator = await getStoreEmulatorPackage(id);
|
const emulator = await getStoreEmulatorPackage(id);
|
||||||
if (!emulator) return status("Not Found");
|
if (!emulator) return status("Not Found");
|
||||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
||||||
const systemsIdSet = new Set(systems.map(s => s.id));
|
|
||||||
|
|
||||||
|
|
||||||
const games: FrontEndGameType[] = [];
|
const games: FrontEndGameType[] = [];
|
||||||
|
|
||||||
|
|
@ -587,28 +575,6 @@ export default new Elysia()
|
||||||
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
|
await plugins.hooks.games.fetchRecommendedGamesForEmulator.promise({ emulator, systems, games: remoteGames });
|
||||||
games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`)));
|
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;
|
return games;
|
||||||
})
|
})
|
||||||
.get('/recommended/games/game/:source/:id', async ({ params: { source, id } }) =>
|
.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 sourceCompaniesSet = new Set(sourceData.metadata.companies);
|
||||||
const sourceGenresSet = new Set(sourceData.metadata.genres);
|
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; })[] = [];
|
const games: (FrontEndGameType & { metadata?: any; })[] = [];
|
||||||
|
|
||||||
|
|
@ -632,35 +598,7 @@ export default new Elysia()
|
||||||
|
|
||||||
games.push(...localGames.map(g => convertLocalToFrontend(g)));
|
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; })[] = [];
|
const remoteGames: (FrontEndGameType & { metadata?: any; })[] = [];
|
||||||
plugins.hooks.games.fetchRecommendedGamesForGame.promise({
|
plugins.hooks.games.fetchRecommendedGamesForGame.promise({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import z from "zod";
|
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 { db, plugins } from "../app";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
|
|
||||||
|
|
@ -93,7 +93,8 @@ export default new Elysia()
|
||||||
if (!remotePlatform) return status("Not Found");
|
if (!remotePlatform) return status("Not Found");
|
||||||
return remotePlatform;
|
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';
|
set.headers["cross-origin-resource-policy"] = 'cross-origin';
|
||||||
|
|
||||||
|
|
@ -112,4 +113,35 @@ export default new Elysia()
|
||||||
set.headers["content-type"] = coverBlob.cover_type;
|
set.headers["content-type"] = coverBlob.cover_type;
|
||||||
}
|
}
|
||||||
return status(200, coverBlob.cover);
|
return status(200, coverBlob.cover);
|
||||||
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 404: z.any() }, params: z.object({ id: z.coerce.number() }) });
|
}, { response: { 200: z.instanceof(Buffer<ArrayBufferLike>), 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");
|
||||||
|
});
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Glob, which } from 'bun';
|
import { Glob } from 'bun';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import * as schema from '@schema/emulators';
|
import { config, taskQueue } from '../../app';
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { config, customEmulators, emulatorsDb, taskQueue } from '../../app';
|
|
||||||
import os from 'node:os';
|
|
||||||
import { cores } from '../../emulatorjs/emulatorjs';
|
|
||||||
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
import { LaunchGameJob } from '../../jobs/launch-game-job';
|
||||||
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
|
import { getStoreEmulatorPackage } from '../../store/services/gamesService';
|
||||||
import { getOrCachedScoopPackage } from '../../store/services/emulatorsService';
|
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)
|
export async function launchCommand (validCommand: CommandEntry, id: FrontEndId, source?: string, sourceId?: string)
|
||||||
{
|
{
|
||||||
if (taskQueue.hasActiveOfType(LaunchGameJob))
|
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));
|
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<string>();
|
|
||||||
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<CommandEntry[]>
|
|
||||||
{
|
|
||||||
|
|
||||||
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<string, string> = {
|
|
||||||
'%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\%=(?<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<EmulatorSourceEntryType | undefined>
|
export async function findStoreEmulatorExec (id: string, emulator?: { systempath: string[]; }): Promise<EmulatorSourceEntryType | undefined>
|
||||||
{
|
{
|
||||||
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
const storeEmulatorFolder = path.join(config.get('downloadPath'), 'emulators', id);
|
||||||
|
|
@ -355,112 +69,3 @@ export async function findStoreEmulatorExec (id: string, emulator?: { systempath
|
||||||
return undefined;
|
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import { RPC_URL, } from "@shared/constants";
|
import { config, db, plugins, taskQueue } from "../../app";
|
||||||
import { config, db, emulatorsDb, plugins, taskQueue } from "../../app";
|
import { eq } from "drizzle-orm";
|
||||||
import { findExecs, getValidLaunchCommands } from "./launchGameService";
|
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { getErrorMessage } from "@/bun/utils";
|
import { getErrorMessage } from "@/bun/utils";
|
||||||
import { checkFiles, getLocalGameMatch } from "./utils";
|
import { checkFiles, getLocalGameMatch, getSourceGameDetailed } from "./utils";
|
||||||
import fs from 'node:fs/promises';
|
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 Elysia from "elysia";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
|
||||||
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
import { LaunchGameJob } from "../../jobs/launch-game-job";
|
||||||
import * as appSchema from "@schema/app";
|
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)
|
constructor(status: GameStatusType, message: string)
|
||||||
{
|
{
|
||||||
|
|
@ -33,7 +29,8 @@ export async function getLocalGame (source: string, id: string)
|
||||||
source: true,
|
source: true,
|
||||||
source_id: true,
|
source_id: true,
|
||||||
igdb_id: true,
|
igdb_id: true,
|
||||||
ra_id: true
|
ra_id: true,
|
||||||
|
main_glob: true
|
||||||
},
|
},
|
||||||
where: getLocalGameMatch(id, source),
|
where: getLocalGameMatch(id, source),
|
||||||
with: {
|
with: {
|
||||||
|
|
@ -44,6 +41,59 @@ export async function getLocalGame (source: string, id: string)
|
||||||
return localGame;
|
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)
|
export async function fixSource (source: string, id: string)
|
||||||
{
|
{
|
||||||
const valid = await validateGameSource(source, id);
|
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) return { valid: true };
|
||||||
if (localGame.source && localGame.source_id)
|
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 });
|
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) 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 };
|
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>
|
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);
|
const localGame = await getLocalGame(source, id);
|
||||||
if (localGame)
|
if (localGame)
|
||||||
{
|
{
|
||||||
const rommPlatform = localGame.platform.slug;
|
const commands = await plugins.hooks.games.buildLaunchCommands.promise({
|
||||||
const esPlatform = await emulatorsDb.query.systemMappings.findFirst({ where: and(eq(emulatorSchema.systemMappings.sourceSlug, rommPlatform), eq(emulatorSchema.systemMappings.source, 'romm')) });
|
source: localGame.source,
|
||||||
|
sourceId: localGame.source_id,
|
||||||
if (esPlatform)
|
id: { source: 'local', id: String(localGame.id) },
|
||||||
{
|
systemSlug: localGame.platform.slug,
|
||||||
if (localGame.path_fs)
|
gamePath: localGame.path_fs,
|
||||||
{
|
mainGlob: localGame.main_glob,
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
if (commands instanceof Error || !commands) return commands;
|
||||||
|
|
||||||
const validCommand = commands.find(c => c.valid);
|
const validCommand = commands.find(c => c.valid);
|
||||||
if (validCommand)
|
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 };
|
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
|
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(', ')}`);
|
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return new CommandSearchError('error', `Missing Platform ${localGame.platform.slug}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -239,6 +242,7 @@ export default function buildStatusResponse ()
|
||||||
}
|
}
|
||||||
else
|
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);
|
const validCommand = await getValidLaunchCommandsForGame(ws.data.params.source, ws.data.params.id);
|
||||||
if (validCommand)
|
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 fileResponse = await fetch(storeGame.file, { method: 'HEAD' });
|
||||||
const size = Number(fileResponse.headers.get('content-length'));
|
const size = Number(fileResponse.headers.get('content-length'));
|
||||||
const stats = await fs.statfs(config.get('downloadPath'));
|
const stats = await fs.statfs(config.get('downloadPath'));
|
||||||
|
|
@ -268,8 +272,10 @@ export default function buildStatusResponse ()
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
ws.send({ status: 'install', details: 'Install' });
|
ws.send({ status: 'install', details: 'Install' });
|
||||||
}
|
}*/
|
||||||
} else
|
|
||||||
|
ws.send({ status: 'install', details: 'Install' });
|
||||||
|
} else if (!localGame)
|
||||||
{
|
{
|
||||||
const files = await plugins.hooks.games.fetchDownloads.promise({
|
const files = await plugins.hooks.games.fetchDownloads.promise({
|
||||||
source: ws.data.params.source,
|
source: ws.data.params.source,
|
||||||
|
|
@ -302,8 +308,9 @@ export default function buildStatusResponse ()
|
||||||
ws.send({ status: 'install', details: 'Install' });
|
ws.send({ status: 'install', details: 'Install' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
ws.send({ status: 'error', error: "No Way To Launch" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ import path from "node:path";
|
||||||
import { config, db, emulatorsDb, plugins } from "../../app";
|
import { config, db, emulatorsDb, plugins } from "../../app";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as schema from "@schema/app";
|
import * as schema from "@schema/app";
|
||||||
import { StoreGameType } from "@shared/constants";
|
import { RPC_URL, StoreGameType } from "@shared/constants";
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
|
||||||
import { extractStoreGameSourceId, getStoreGame } from "../../store/services/gamesService";
|
|
||||||
import { hashFile } from "@/bun/utils";
|
import { hashFile } from "@/bun/utils";
|
||||||
|
import { host } from "@/bun/utils/host";
|
||||||
|
import secrets from "../../secrets";
|
||||||
|
|
||||||
export async function calculateSize (installPath: string | null)
|
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));
|
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)
|
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));
|
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,
|
platform_display_name: g.platform?.name ?? null,
|
||||||
id: { id: String(g.id), source: 'local' },
|
id: { id: String(g.id), source: 'local' },
|
||||||
updated_at: g.created_at,
|
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_id: g.source_id,
|
||||||
source: g.source,
|
source: g.source,
|
||||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
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",
|
platform_display_name: g.platform?.name ?? "Local",
|
||||||
id: { id: String(g.id), source: 'local' },
|
id: { id: String(g.id), source: 'local' },
|
||||||
updated_at: g.created_at,
|
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_id: g.source_id,
|
||||||
source: g.source,
|
source: g.source,
|
||||||
path_platform_cover: `/api/romm/platform/local/${g.platform_id}/cover`,
|
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,
|
fs_size_bytes: fileSize,
|
||||||
missing: !exists,
|
missing: !exists,
|
||||||
local: true,
|
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: {
|
metadata: {
|
||||||
genres: g.metadata.genres ?? [],
|
genres: g.metadata.genres ?? [],
|
||||||
companies: g.metadata.companies ?? [],
|
companies: g.metadata.companies ?? [],
|
||||||
|
|
@ -96,74 +106,6 @@ export async function convertLocalToFrontendDetailed (g: typeof schema.games.$in
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function convertStoreToFrontend (system: string, id: string, storeGame: StoreGameType): Promise<FrontEndGameType>
|
|
||||||
{
|
|
||||||
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<FrontEndGameTypeDetailed>
|
|
||||||
{
|
|
||||||
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)
|
export async function getLocalGameDetailed (match: any)
|
||||||
{
|
{
|
||||||
const localGame = await db.query.games.findFirst({
|
const localGame = await db.query.games.findFirst({
|
||||||
|
|
@ -182,7 +124,7 @@ export async function getLocalGameDetailed (match: any)
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSourceGameDetailed (source: string, id: string)
|
export async function getSourceGameDetailed (source: string, id: string, options?: { sourceOnly?: boolean; })
|
||||||
{
|
{
|
||||||
if (source === 'local')
|
if (source === 'local')
|
||||||
{
|
{
|
||||||
|
|
@ -194,30 +136,13 @@ export async function getSourceGameDetailed (source: string, id: string)
|
||||||
{
|
{
|
||||||
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
const localGame = await getLocalGameDetailed(getLocalGameMatch(id, source));
|
||||||
|
|
||||||
if (source === 'store')
|
|
||||||
{
|
|
||||||
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 });
|
const remoteGame = await plugins.hooks.games.fetchGame.promise({ source, id, localGame });
|
||||||
if (remoteGame)
|
if (localGame && options?.sourceOnly !== true)
|
||||||
{
|
|
||||||
return remoteGame;
|
|
||||||
} else if (localGame)
|
|
||||||
{
|
{
|
||||||
return localGame;
|
return localGame;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
return remoteGame;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { AuthHooks } from "./auth";
|
import { AuthHooks } from "./auth";
|
||||||
import { EmulatorHooks } from "./emulators";
|
import { EmulatorHooks } from "./emulators";
|
||||||
import { GameHooks } from "./games";
|
import { GameHooks } from "./games";
|
||||||
|
import { StoreHooks } from "./store";
|
||||||
|
|
||||||
export class GameflowHooks
|
export class GameflowHooks
|
||||||
{
|
{
|
||||||
games = new GameHooks();
|
games = new GameHooks();
|
||||||
emulators = new EmulatorHooks();
|
emulators = new EmulatorHooks();
|
||||||
auth = new AuthHooks();
|
auth = new AuthHooks();
|
||||||
|
store = new StoreHooks();
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +22,8 @@ export class EmulatorHooks
|
||||||
* Triggered when emulator is downloaded or updated
|
* Triggered when emulator is downloaded or updated
|
||||||
*/
|
*/
|
||||||
emulatorPostInstall = new AsyncSeriesHook<[ctx: EmulatorPostInstallContext], { emulator: string; }>(['ctx']);
|
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()
|
constructor()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ import { SyncBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfal
|
||||||
|
|
||||||
export class GameHooks
|
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
|
/** override the launch command for an emulator
|
||||||
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
|
* @param ctx.autoValidCommands The auto generated command for example based on the ES-DE listing
|
||||||
* @param ctx.emulator The emulator ID if any
|
* @param ctx.emulator The emulator ID if any
|
||||||
|
|
@ -20,7 +28,7 @@ export class GameHooks
|
||||||
id: FrontEndId;
|
id: FrontEndId;
|
||||||
platformSlug?: string;
|
platformSlug?: string;
|
||||||
};
|
};
|
||||||
}], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']);
|
}], { args: string[], savesPath?: SaveSlots; env?: Record<string, string>; } | undefined, { emulator: string; }>(['ctx']);
|
||||||
/**
|
/**
|
||||||
* Is the given emulator for the given command supported
|
* 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.
|
* @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: {
|
fetchGames = new AsyncSeriesHook<[ctx: {
|
||||||
query: GameListFilterType;
|
query: GameListFilterType;
|
||||||
games: FrontEndGameTypeWithIds[];
|
games: FrontEndGameTypeWithIds[];
|
||||||
filters: FrontEndFilterSets;
|
|
||||||
}]>(['ctx']);
|
}]>(['ctx']);
|
||||||
fetchFilters = new AsyncSeriesHook<[ctx: {
|
fetchFilters = new AsyncSeriesHook<[ctx: {
|
||||||
|
source?: string;
|
||||||
filters: FrontEndFilterSets;
|
filters: FrontEndFilterSets;
|
||||||
}]>(['ctx']);
|
}]>(['ctx']);
|
||||||
fetchGame = new AsyncSeriesBailHook<[ctx: {
|
fetchGame = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
|
@ -58,7 +66,12 @@ export class GameHooks
|
||||||
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
|
fetchDownloads = new AsyncSeriesBailHook<[ctx: {
|
||||||
source: string;
|
source: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
downloadId?: string;
|
||||||
}], DownloadInfo | undefined>(['ctx']);
|
}], DownloadInfo | undefined>(['ctx']);
|
||||||
|
fetchRomFiles = new AsyncSeriesBailHook<[ctx: {
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
}], string[] | undefined>(['ctx']);
|
||||||
fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: {
|
fetchRecommendedGamesForGame = new AsyncSeriesHook<[ctx: {
|
||||||
game: FrontEndGameTypeDetailed,
|
game: FrontEndGameTypeDetailed,
|
||||||
games: (FrontEndGameType & { metadata?: any; })[];
|
games: (FrontEndGameType & { metadata?: any; })[];
|
||||||
|
|
@ -73,28 +86,39 @@ export class GameHooks
|
||||||
id: string;
|
id: string;
|
||||||
}], FrontEndPlatformType | undefined>(['ctx']);
|
}], FrontEndPlatformType | undefined>(['ctx']);
|
||||||
platformLookup = new AsyncSeriesBailHook<[ctx: {
|
platformLookup = new AsyncSeriesBailHook<[ctx: {
|
||||||
source: string;
|
source?: string;
|
||||||
id: string;
|
id?: string;
|
||||||
}], { slug: string; } | undefined>(['ctx']);
|
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: {
|
fetchPlatforms = new AsyncSeriesHook<[ctx: {
|
||||||
platforms: FrontEndPlatformType[];
|
platforms: FrontEndPlatformType[];
|
||||||
}]>(['ctx']);
|
}]>(['ctx']);
|
||||||
prePlay = new AsyncSeriesHook<[ctx: {
|
prePlay = new AsyncSeriesHook<[ctx: {
|
||||||
source: string,
|
source: string,
|
||||||
id: string;
|
id: string;
|
||||||
saveFolderPath?: string;
|
saveFolderSlots: Record<string, { cwd: string; }>;
|
||||||
setProgress: (progress: number, state: string) => void,
|
setProgress: (progress: number, state: string) => void,
|
||||||
command: CommandEntry;
|
command: CommandEntry;
|
||||||
gameInfo: {
|
gameInfo: {
|
||||||
platformSlug?: string;
|
platformSlug?: string;
|
||||||
};
|
};
|
||||||
}]>(["ctx"]);
|
}]>(["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: {
|
postPlay = new AsyncSeriesHook<[ctx: {
|
||||||
source: string,
|
source: string,
|
||||||
id: string;
|
id: string;
|
||||||
saveFolderPath?: string;
|
saveFolderSlots?: Record<string, { cwd: string; }>;
|
||||||
changedSaveFiles: SaveFileChange[],
|
changedSaveFiles: { subPath: string, cwd: string; }[],
|
||||||
validChangedSaveFiles: SaveFileChange[],
|
validChangedSaveFiles: Record<string, SaveFileChange>,
|
||||||
command: CommandEntry;
|
command: CommandEntry;
|
||||||
gameInfo: {
|
gameInfo: {
|
||||||
platformSlug?: string;
|
platformSlug?: string;
|
||||||
|
|
|
||||||
10
src/bun/api/hooks/store.ts
Normal file
10
src/bun/api/hooks/store.ts
Normal file
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ import * as schema from "@schema/app";
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import path, { join } from 'node:path';
|
import path, { join } from 'node:path';
|
||||||
import { config, db, emulatorsDb, events, plugins } from "../app";
|
import { config, db, emulatorsDb, events, plugins } from "../app";
|
||||||
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
|
|
||||||
import * as igdb from 'ts-igdb-client';
|
import * as igdb from 'ts-igdb-client';
|
||||||
import secrets from "../secrets";
|
import secrets from "../secrets";
|
||||||
import { simulateProgress } from "@/bun/utils";
|
import { simulateProgress } from "@/bun/utils";
|
||||||
|
|
@ -13,17 +12,16 @@ import { Downloader } from "@/bun/utils/downloader";
|
||||||
import Seven from 'node-7z';
|
import Seven from 'node-7z';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { checkFiles } from "../games/services/utils";
|
import { checkFiles } from "../games/services/utils";
|
||||||
import { ensureDir, existsSync } from "fs-extra";
|
import { ensureDir, move } from "fs-extra";
|
||||||
import { path7za } from "7zip-bin";
|
import { path7za } from "7zip-bin";
|
||||||
import slugify from 'slugify';
|
|
||||||
import StreamZip from 'node-stream-zip';
|
import StreamZip from 'node-stream-zip';
|
||||||
import { createExtractorFromFile } from 'node-unrar-js';
|
|
||||||
import { which } from "bun";
|
import { which } from "bun";
|
||||||
|
|
||||||
interface JobConfig
|
interface JobConfig
|
||||||
{
|
{
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
dryDownload?: boolean;
|
dryDownload?: boolean;
|
||||||
|
downloadId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InstallJobStates = 'download' | 'extract';
|
export type InstallJobStates = 'download' | 'extract';
|
||||||
|
|
@ -55,34 +53,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
const downloadPath = config.get('downloadPath');
|
const downloadPath = config.get('downloadPath');
|
||||||
let info: DownloadInfo | undefined;
|
let info: DownloadInfo | undefined;
|
||||||
|
|
||||||
switch (this.source)
|
info = await plugins.hooks.games.fetchDownloads.promise({ source: this.source, id: this.gameId, downloadId: this.config?.downloadId });
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
|
if (!info) throw new Error(`Could not find downloader for source ${this.source}`);
|
||||||
|
|
||||||
|
|
@ -116,9 +87,10 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
{
|
{
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
const progressDelta = 1 / downloadedFiles.length;
|
const progressDelta = 1 / downloadedFiles.length;
|
||||||
|
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path);
|
||||||
|
|
||||||
for (const filePath of downloadedFiles)
|
for (const filePath of downloadedFiles)
|
||||||
{
|
{
|
||||||
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path);
|
|
||||||
await new Promise(async (resolve, reject) =>
|
await new Promise(async (resolve, reject) =>
|
||||||
{
|
{
|
||||||
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
|
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
|
||||||
|
|
@ -176,8 +148,23 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
progress += progressDelta * 100;
|
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<never, InstallJobStates>
|
||||||
if (!existingPlatform)
|
if (!existingPlatform)
|
||||||
{
|
{
|
||||||
// TODO: use something else than the romm demo as CDN
|
// 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)
|
if (!esPlatform && !info.platform)
|
||||||
{
|
{
|
||||||
|
|
@ -251,7 +246,7 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
cover_type: platformCover.headers.get('content-type'),
|
cover_type: platformCover.headers.get('content-type'),
|
||||||
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
|
name: info.platform?.name ?? esPlatform?.system.fullname ?? '',
|
||||||
family_name: info.platform?.family_name,
|
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
|
// TODO: add ES slug once I have better way to query ES
|
||||||
|
|
@ -278,22 +273,20 @@ export class InstallJob implements IJob<never, InstallJobStates>
|
||||||
name: info.name,
|
name: info.name,
|
||||||
cover,
|
cover,
|
||||||
cover_type: coverResponse.headers.get('content-type'),
|
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 });
|
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' });
|
const igdbLookup = await plugins.hooks.games.gameLookup.promise({ source: 'igdb', id: String(info.igdb_id) });
|
||||||
if (access_token)
|
if (igdbLookup) return igdbLookup.screenshotUrls;
|
||||||
{
|
return [];
|
||||||
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!));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pre-fetch screenshots
|
// pre-fetch screenshots
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { IJob } from "../task-queue";
|
||||||
import { LaunchGameJob } from "./launch-game-job";
|
import { LaunchGameJob } from "./launch-game-job";
|
||||||
import { BiosDownloadJob } from "./bios-download-job";
|
import { BiosDownloadJob } from "./bios-download-job";
|
||||||
import { InstallJob } from "./install-job";
|
import { InstallJob } from "./install-job";
|
||||||
|
import ReloadPluginsJob from "./reload-plugins-job";
|
||||||
|
|
||||||
function registerJob<
|
function registerJob<
|
||||||
const Path extends string,
|
const Path extends string,
|
||||||
|
|
@ -107,4 +108,5 @@ export const jobs = new Elysia({ prefix: '/api/jobs' })
|
||||||
.use(registerJob(UpdateStoreJob))
|
.use(registerJob(UpdateStoreJob))
|
||||||
.use(registerJob(BiosDownloadJob))
|
.use(registerJob(BiosDownloadJob))
|
||||||
.use(registerJob(InstallJob))
|
.use(registerJob(InstallJob))
|
||||||
|
.use(registerJob(ReloadPluginsJob))
|
||||||
.use(registerJob(EmulatorDownloadJob));
|
.use(registerJob(EmulatorDownloadJob));
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
validCommand: CommandEntry;
|
validCommand: CommandEntry;
|
||||||
gameSource?: string;
|
gameSource?: string;
|
||||||
gameSourceId?: string;
|
gameSourceId?: string;
|
||||||
changedSaveFiles: Map<string, SaveFileChange>;
|
changedSaveFiles: Map<string, { subPath: string, cwd: string; }>;
|
||||||
saveFolderPath?: string;
|
saveSlots: SaveSlots = {};
|
||||||
|
|
||||||
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
|
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
|
||||||
{
|
{
|
||||||
|
|
@ -47,9 +47,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
source,
|
source,
|
||||||
id,
|
id,
|
||||||
command: this.validCommand,
|
command: this.validCommand,
|
||||||
saveFolderPath: this.saveFolderPath,
|
|
||||||
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
|
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
|
||||||
validChangedSaveFiles: [],
|
validChangedSaveFiles: {},
|
||||||
gameInfo
|
gameInfo
|
||||||
}).catch(e => console.error(e));
|
}).catch(e => console.error(e));
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +58,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
return plugins.hooks.games.prePlay.promise({
|
return plugins.hooks.games.prePlay.promise({
|
||||||
source: this.gameSource ?? this.gameId.source,
|
source: this.gameSource ?? this.gameId.source,
|
||||||
id: this.gameSourceId ?? this.gameId.id,
|
id: this.gameSourceId ?? this.gameId.id,
|
||||||
saveFolderPath: this.saveFolderPath,
|
saveFolderSlots: this.saveSlots,
|
||||||
command: this.validCommand,
|
command: this.validCommand,
|
||||||
setProgress: setProgress,
|
setProgress: setProgress,
|
||||||
gameInfo
|
gameInfo
|
||||||
|
|
@ -125,7 +124,9 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
cwd: this.validCommand.startDir,
|
cwd: this.validCommand.startDir,
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
env: {
|
env: {
|
||||||
}
|
...process.env,
|
||||||
|
...this.validCommand.env
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
context.setProgress(0, "playing");
|
context.setProgress(0, "playing");
|
||||||
|
|
@ -138,14 +139,14 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
spawnGame.on('error', e =>
|
spawnGame.on('error', e =>
|
||||||
{
|
{
|
||||||
console.error(e);
|
console.error(e);
|
||||||
reject(e);
|
resolve(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
game = spawnGame;
|
game = spawnGame;
|
||||||
}
|
}
|
||||||
else if (this.validCommand.metadata.emulatorBin)
|
else if (this.validCommand.metadata.emulatorBin)
|
||||||
{
|
{
|
||||||
this.saveFolderPath = commandArgs.savesPath;
|
this.saveSlots = commandArgs.savesPath ?? {};
|
||||||
|
|
||||||
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
|
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
|
||||||
|
|
||||||
|
|
@ -154,12 +155,15 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
cwd: this.validCommand.startDir,
|
cwd: this.validCommand.startDir,
|
||||||
signal: context.abortSignal,
|
signal: context.abortSignal,
|
||||||
env: {
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...commandArgs.env
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
context.setProgress(0, "playing");
|
context.setProgress(0, "playing");
|
||||||
|
|
||||||
if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
|
// TODO: this isn't really useful, maybe add it later if needed
|
||||||
|
/*if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
|
||||||
{
|
{
|
||||||
const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal });
|
const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal });
|
||||||
console.log("Starting To Watch", commandArgs.savesPath, "for save file changes");
|
console.log("Starting To Watch", commandArgs.savesPath, "for save file changes");
|
||||||
|
|
@ -168,7 +172,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
if (typeof filename === 'string')
|
if (typeof filename === 'string')
|
||||||
{
|
{
|
||||||
console.log("Save File Changed", filename);
|
console.log("Save File Changed", filename);
|
||||||
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath!, shared: true });
|
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -177,7 +181,7 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
|
||||||
savesWatcher.close();
|
savesWatcher.close();
|
||||||
console.log("Closing Save File Watching for", commandArgs.savesPath);
|
console.log("Closing Save File Watching for", commandArgs.savesPath);
|
||||||
});
|
});
|
||||||
}
|
}*/
|
||||||
|
|
||||||
bunGame.exited.then(e =>
|
bunGame.exited.then(e =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
15
src/bun/api/jobs/reload-plugins-job.ts
Normal file
15
src/bun/api/jobs/reload-plugins-job.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import z from "zod";
|
||||||
|
import { IJob, JobContext } from "../task-queue";
|
||||||
|
import { plugins } from "../app";
|
||||||
|
|
||||||
|
export default class ReloadPluginsJob implements IJob<never, string>
|
||||||
|
{
|
||||||
|
static id = "reload-plugins-job" as const;
|
||||||
|
static dataSchema = z.never();
|
||||||
|
group = "reload-plugins";
|
||||||
|
|
||||||
|
async start (context: JobContext<IJob<never, string>, never, string>)
|
||||||
|
{
|
||||||
|
await plugins.reloadAll(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 desc from './package.json';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
|
|
@ -7,7 +7,7 @@ export default class CEMUIntegration implements PluginType
|
||||||
{
|
{
|
||||||
emulator = 'CEMU';
|
emulator = 'CEMU';
|
||||||
|
|
||||||
load (ctx: PluginContextType)
|
async load (ctx: PluginLoadingContextType)
|
||||||
{
|
{
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
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}`);
|
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args, savesPath: savesPath };
|
return { args, savesPath: { cemu: { cwd: savesPath } } };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"description": "CEMU Emulator Integration",
|
"description": "CEMU Emulator Integration",
|
||||||
"main": "./cemu.ts",
|
"main": "./cemu.ts",
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png",
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Cemu_Emulator_Official_Logo.png",
|
||||||
|
"category": "emulators",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"emulator",
|
"emulator",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import { config } from "@/bun/api/app";
|
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 path from 'node:path';
|
||||||
import desc from './package.json';
|
import desc from './package.json';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
|
|
@ -10,7 +10,7 @@ export default class DOLPHINIntegration implements PluginType
|
||||||
{
|
{
|
||||||
emulator = 'DOLPHIN';
|
emulator = 'DOLPHIN';
|
||||||
|
|
||||||
load (ctx: PluginContextType)
|
async load (ctx: PluginLoadingContextType)
|
||||||
{
|
{
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
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;
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"description": "DOLPHIN Emulator Integration",
|
"description": "DOLPHIN Emulator Integration",
|
||||||
"main": "./dolphin.ts",
|
"main": "./dolphin.ts",
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg",
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/53/Dolphin_Emulator_Logo_Refresh.svg",
|
||||||
|
"category": "emulators",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"emulator",
|
"emulator",
|
||||||
|
|
|
||||||
|
|
@ -128,10 +128,10 @@ async function getGCSavePaths (romPath: string, savesPath: string, location: Dol
|
||||||
const cardPath = join(savesPath, "GC", region);
|
const cardPath = join(savesPath, "GC", region);
|
||||||
|
|
||||||
const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`);
|
const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`);
|
||||||
const saves: SaveFileChange[] = [];
|
const saves: string[] = [];
|
||||||
for await (const file of glob.scan(cardPath))
|
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;
|
return saves;
|
||||||
|
|
@ -145,7 +145,7 @@ export async function getType (romPath: string, bundledEmulatorDir?: string): Pr
|
||||||
return isGameCube ? "gamecube" : "wii";
|
return isGameCube ? "gamecube" : "wii";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise<SaveFileChange[]>
|
export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise<string[]>
|
||||||
{
|
{
|
||||||
const location = await findDolphinTool(bundledEmulatorDir);
|
const location = await findDolphinTool(bundledEmulatorDir);
|
||||||
const gameId = await readGameId(romPath, location);
|
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 folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase();
|
||||||
const rootFolder = join(savesPath, "Wii", "title", "00010000", folder);
|
const rootFolder = join(savesPath, "Wii", "title", "00010000", folder);
|
||||||
const files = await fs.readdir(rootFolder, { recursive: true });
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"description": "PCSX2 Emulator Integration",
|
"description": "PCSX2 Emulator Integration",
|
||||||
"main": "./pcsx2.ts",
|
"main": "./pcsx2.ts",
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/2/2b/PCSX2_logo4.png",
|
||||||
|
"category": "emulators",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"emulator",
|
"emulator",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
import { config } from "@/bun/api/app";
|
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 defaultConfig from './PCSX2.ini' with { type: 'file' };
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureDir } from "fs-extra";
|
import { ensureDir } from "fs-extra";
|
||||||
|
|
@ -11,7 +11,7 @@ export default class PCSX2Integration implements PluginType
|
||||||
{
|
{
|
||||||
emulator = "PCSX2";
|
emulator = "PCSX2";
|
||||||
|
|
||||||
load (ctx: PluginContextType)
|
async load (ctx: PluginLoadingContextType)
|
||||||
{
|
{
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
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));
|
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 };
|
return { args };
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"description": "PPSSPP Emulator Integration",
|
"description": "PPSSPP Emulator Integration",
|
||||||
"main": "./ppsspp.ts",
|
"main": "./ppsspp.ts",
|
||||||
"icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png",
|
"icon": "https://www.ppsspp.org/static/img/platform/ppsspp-icon.png",
|
||||||
|
"category": "emulators",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"emulator",
|
"emulator",
|
||||||
|
|
|
||||||
|
|
@ -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 desc from './package.json';
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
|
import configFilePathWin32 from './win32/ppsspp.ini' with { type: 'file' };
|
||||||
|
|
@ -15,7 +15,7 @@ export default class PPSSPPIntegration implements PluginType
|
||||||
{
|
{
|
||||||
emulator = "PPSSPP";
|
emulator = "PPSSPP";
|
||||||
|
|
||||||
load (ctx: PluginContextType)
|
async load (ctx: PluginLoadingContextType)
|
||||||
{
|
{
|
||||||
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
|
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, {}));
|
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 };
|
return { args };
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"description": "XEMU Emulator Integration",
|
"description": "XEMU Emulator Integration",
|
||||||
"main": "./xemu.ts",
|
"main": "./xemu.ts",
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg",
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg",
|
||||||
|
"category": "emulators",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"emulator",
|
"emulator",
|
||||||
|
|
|
||||||
|
|
@ -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 desc from './package.json';
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
@ -10,7 +10,7 @@ export default class XEMUIntegration implements PluginType
|
||||||
{
|
{
|
||||||
emulator = 'XEMU';
|
emulator = 'XEMU';
|
||||||
|
|
||||||
load (ctx: PluginContextType)
|
async load (ctx: PluginLoadingContextType)
|
||||||
{
|
{
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"description": "XENIA Emulator Integration",
|
"description": "XENIA Emulator Integration",
|
||||||
"main": "./xenia.ts",
|
"main": "./xenia.ts",
|
||||||
"icon": "https://xenia.jp/images/logo-256x256.png",
|
"icon": "https://xenia.jp/images/logo-256x256.png",
|
||||||
|
"category": "emulators",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"emulator",
|
"emulator",
|
||||||
|
|
|
||||||
|
|
@ -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 desc from './package.json';
|
||||||
import { GameflowHooks } from "@/bun/api/hooks/app";
|
import { GameflowHooks } from "@/bun/api/hooks/app";
|
||||||
import { config } from "@/bun/api/app";
|
import { config } from "@/bun/api/app";
|
||||||
|
|
@ -68,9 +68,10 @@ export default class XENIAIntegration implements PluginType
|
||||||
if (ctx.autoValidCommand.metadata.romPath)
|
if (ctx.autoValidCommand.metadata.romPath)
|
||||||
{
|
{
|
||||||
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
|
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
|
||||||
|
return { args, savesPath: { xenia: { cwd: finalSavesPath } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { args, savesPath: finalSavesPath };
|
return { args };
|
||||||
};
|
};
|
||||||
|
|
||||||
return { args };
|
return { args };
|
||||||
|
|
@ -82,7 +83,7 @@ export default class XENIAIntegration implements PluginType
|
||||||
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] };
|
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.emulator }, this.handleEmulatorLaunchSupport);
|
||||||
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, 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)
|
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
|
||||||
{
|
{
|
||||||
const files = await fs.readdir(saveFolderPath, { recursive: true });
|
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 };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string>();
|
||||||
|
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<CommandEntry[]>
|
||||||
|
{
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
'%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\%=(?<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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<typeof SettingsSchema>;
|
||||||
|
const loginTokenUrlRegex = /http:\/\/[\w\d:\-@\[\]\.\/?=]+/gm;
|
||||||
|
|
||||||
|
export default class RcloneIntegration implements PluginType<SettingsType>
|
||||||
|
{
|
||||||
|
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<SettingsType>)
|
||||||
|
{
|
||||||
|
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<SettingsType>)
|
||||||
|
{
|
||||||
|
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<string, string> | 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<SettingsType>)
|
||||||
|
{
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<T> (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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"description": "ROMM Server Integration",
|
"description": "ROMM Server Integration",
|
||||||
"main": "./romm.ts",
|
"main": "./romm.ts",
|
||||||
"icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg",
|
"icon": "https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg",
|
||||||
|
"category": "sources",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"integration",
|
"integration",
|
||||||
"romm"
|
"romm"
|
||||||
|
|
|
||||||
|
|
@ -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 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 { config, events } from "@/bun/api/app";
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs/promises';
|
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 { getAuthToken } from "@/clients/romm/core/auth.gen";
|
||||||
import { client } from "@/clients/romm/client.gen";
|
import { client } from "@/clients/romm/client.gen";
|
||||||
import { validateGameSource } from "@/bun/api/games/services/statusService";
|
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<typeof SettingsSchema>;
|
||||||
|
|
||||||
|
export default class RommIntegration implements PluginType<SettingsType>
|
||||||
{
|
{
|
||||||
|
settingsSchema = SettingsSchema;
|
||||||
isSteamDeck = false;
|
isSteamDeck = false;
|
||||||
orderByMap: Record<string, string> = {
|
orderByMap: Record<string, string> = {
|
||||||
added: "created_at",
|
added: "created_at",
|
||||||
|
|
@ -54,7 +62,7 @@ export default class RommIntegration implements PluginType
|
||||||
{
|
{
|
||||||
const game: FrontEndGameType = {
|
const game: FrontEndGameType = {
|
||||||
id: { id: String(rom.id), source: 'romm' },
|
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,
|
last_played: rom.rom_user.last_played !== null ? new Date(rom.rom_user.last_played) : null,
|
||||||
updated_at: new Date(rom.created_at),
|
updated_at: new Date(rom.created_at),
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|
@ -83,8 +91,8 @@ export default class RommIntegration implements PluginType
|
||||||
fs_size_bytes: rom.fs_size_bytes,
|
fs_size_bytes: rom.fs_size_bytes,
|
||||||
local: false,
|
local: false,
|
||||||
missing: rom.missing_from_fs,
|
missing: rom.missing_from_fs,
|
||||||
imdb_id: rom.igdb_id ?? undefined,
|
igdb_id: rom.igdb_id,
|
||||||
ra_id: rom.ra_id ?? undefined,
|
ra_id: rom.ra_id,
|
||||||
metadata: {
|
metadata: {
|
||||||
age_ratings: rom.metadatum.age_ratings,
|
age_ratings: rom.metadatum.age_ratings,
|
||||||
genres: rom.metadatum.genres,
|
genres: rom.metadatum.genres,
|
||||||
|
|
@ -126,15 +134,12 @@ export default class RommIntegration implements PluginType
|
||||||
return detailed;
|
return detailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setup ()
|
async load (ctx: PluginLoadingContextType<SettingsType>)
|
||||||
{
|
{
|
||||||
this.isSteamDeck = isSteamDeckGameMode();
|
this.isSteamDeck = isSteamDeckGameMode();
|
||||||
await this.updateClient();
|
await this.updateClient();
|
||||||
}
|
|
||||||
|
|
||||||
load (ctx: PluginContextType)
|
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games }) =>
|
||||||
{
|
|
||||||
ctx.hooks.games.fetchGames.tapPromise(desc.name, async ({ query, games, filters }) =>
|
|
||||||
{
|
{
|
||||||
if (((!query.platform_source || query.platform_source === 'romm') || !!query.collection_id) && (!query.source || query.source === 'romm'))
|
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,
|
limit: query.limit,
|
||||||
offset: query.offset,
|
offset: query.offset,
|
||||||
order_by: this.orderByMap[query.orderBy ?? ''],
|
order_by: this.orderByMap[query.orderBy ?? ''],
|
||||||
with_filter_values: true,
|
with_filter_values: false,
|
||||||
genres: query.genres,
|
genres: query.genres,
|
||||||
genres_logic: "all",
|
genres_logic: "all",
|
||||||
age_ratings: query.age_ratings,
|
age_ratings: query.age_ratings,
|
||||||
|
|
@ -154,12 +159,6 @@ export default class RommIntegration implements PluginType
|
||||||
}, throwOnError: true
|
}, 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 =>
|
games.push(...rommGames.data.items.map(g =>
|
||||||
{
|
{
|
||||||
const game: FrontEndGameTypeWithIds = {
|
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 });
|
const rommFilters = await getRomFiltersApiRomsFiltersGet({ throwOnError: true });
|
||||||
rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r));
|
rommFilters.data.age_ratings.forEach(r => filters.age_ratings.add(r));
|
||||||
rommFilters.data.companies.forEach(r => filters.companies.add(r));
|
rommFilters.data.companies.forEach(r => filters.companies.add(r));
|
||||||
|
|
@ -188,7 +189,7 @@ export default class RommIntegration implements PluginType
|
||||||
await this.updateClient();
|
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;
|
if (source !== 'romm') return;
|
||||||
|
|
||||||
|
|
@ -196,13 +197,6 @@ export default class RommIntegration implements PluginType
|
||||||
if (rom.data)
|
if (rom.data)
|
||||||
{
|
{
|
||||||
const romGame = await this.convertRomToFrontendDetailed(rom.data);
|
const romGame = await this.convertRomToFrontendDetailed(rom.data);
|
||||||
if (localGame)
|
|
||||||
{
|
|
||||||
return {
|
|
||||||
...romGame,
|
|
||||||
...localGame,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return romGame;
|
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 (source !== 'romm' || !ctx.config.get('savesSync')) return;
|
||||||
if (saveFolderPath)
|
if (!saveFolderSlots) return;
|
||||||
|
|
||||||
|
for await (const [slot, { cwd }] of Object.entries(saveFolderSlots))
|
||||||
{
|
{
|
||||||
setProgress(0, "saves");
|
setProgress(0, "saves");
|
||||||
|
|
||||||
|
|
@ -418,53 +414,38 @@ export default class RommIntegration implements PluginType
|
||||||
console.error(saveFiles.error);
|
console.error(saveFiles.error);
|
||||||
} else
|
} 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 auth = await this.getAuthToken();
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (auth)
|
if (auth)
|
||||||
headers['Authorization'] = 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)
|
if (!saveResponse.ok)
|
||||||
{
|
{
|
||||||
console.error("Error downloading save", saveResponse.statusText);
|
console.error("Error downloading save", saveResponse.statusText);
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
await Bun.write(savePath, saveResponse);
|
|
||||||
console.log("Loaded", savePath);
|
const saveArchive = new Bun.Archive(await saveResponse.blob());
|
||||||
setProgress((i / saveFiles.data.slots.length) * 100, "saves");
|
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);
|
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);
|
const sourceValidation = await validateGameSource(source, id);
|
||||||
if (!sourceValidation.valid)
|
if (!sourceValidation.valid)
|
||||||
|
|
@ -473,7 +454,7 @@ export default class RommIntegration implements PluginType
|
||||||
return;
|
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) } });
|
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
|
||||||
if (saveFiles.error)
|
if (saveFiles.error)
|
||||||
|
|
@ -494,29 +475,31 @@ export default class RommIntegration implements PluginType
|
||||||
if (!finalSavePaths.some(f => f.subPath === subPath))
|
if (!finalSavePaths.some(f => f.subPath === subPath))
|
||||||
{
|
{
|
||||||
// Add newer files to the list, maybe they were changed offscreen.
|
// 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)
|
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);
|
const savesArray = Array.isArray(change.subPath) ? change.subPath : [change.subPath];
|
||||||
if (!await fs.exists(absolutePath)) return;
|
|
||||||
const stat = await fs.stat(absolutePath);
|
// TODO: handle directories
|
||||||
if (stat.isDirectory()) return;
|
const archive = new Bun.Archive(Object.fromEntries(savesArray.map(s => [s, Bun.file(path.join(change.cwd, s))])));
|
||||||
const data: FormData = new FormData();
|
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`);
|
const url = new URL(`${config.get('rommAddress')}/api/saves`);
|
||||||
url.searchParams.set('rom_id', id);
|
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', "true");
|
||||||
url.searchParams.set('autocleanup_limit', "2");
|
url.searchParams.set('autocleanup_limit', "2");
|
||||||
if (command.emulator)
|
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 }) =>
|
||||||
|
{
|
||||||
|
let platform: PlatformSchema | undefined = undefined;
|
||||||
|
|
||||||
|
if (id && source)
|
||||||
{
|
{
|
||||||
if (source !== 'romm') return;
|
if (source !== 'romm') return;
|
||||||
const platforms = await this.getAllRommPlatforms();
|
const platforms = await this.getAllRommPlatforms();
|
||||||
return platforms.find(p => p.id === Number(id));
|
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 }) =>
|
ctx.hooks.games.searchGame.tapPromise(desc.name, async ({ source, igdb_id, ra_id }) =>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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<FrontEndGameType>
|
||||||
|
{
|
||||||
|
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<FrontEndGameTypeDetailed>
|
||||||
|
{
|
||||||
|
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<string, {
|
||||||
|
cwd: string;
|
||||||
|
globs: string[];
|
||||||
|
}> | 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import { GameflowHooks } from "../hooks/app";
|
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 { 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
|
export class PluginManager
|
||||||
{
|
{
|
||||||
|
|
@ -11,10 +20,11 @@ export class PluginManager
|
||||||
plugin: PluginType;
|
plugin: PluginType;
|
||||||
description: PluginDescriptionType,
|
description: PluginDescriptionType,
|
||||||
source: PluginSourceType;
|
source: PluginSourceType;
|
||||||
|
config?: Conf;
|
||||||
|
|
||||||
}> = {};
|
}> = {};
|
||||||
|
|
||||||
async register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
|
register (plugin: PluginType, description: PluginDescriptionType, source: PluginSourceType)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -24,15 +34,29 @@ export class PluginManager
|
||||||
}
|
}
|
||||||
else
|
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] = {
|
this.plugins[description.name] = {
|
||||||
enabled: !config.get('disabledPlugins').includes(description.name),
|
enabled: !config.get('disabledPlugins').includes(description.name),
|
||||||
loaded: false,
|
loaded: false,
|
||||||
plugin: plugin,
|
plugin: plugin,
|
||||||
source: source,
|
source: source,
|
||||||
description: description
|
description: description,
|
||||||
|
config: pluginConfig
|
||||||
};
|
};
|
||||||
this.reload(description.name);
|
|
||||||
console.log("Plugin", description.name, "registered");
|
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];
|
const plugin = this.plugins[name];
|
||||||
if (plugin)
|
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)
|
if (plugin.loaded)
|
||||||
{
|
{
|
||||||
plugin.plugin.onBeforeReload?.(ctx);
|
await plugin.plugin.cleanup?.();
|
||||||
plugin.loaded = false;
|
plugin.loaded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (plugin.enabled)
|
if (plugin.enabled || plugin.description.canDisable === false)
|
||||||
{
|
{
|
||||||
plugin.plugin.load(ctx);
|
await plugin.plugin.load(ctx);
|
||||||
plugin.loaded = true;
|
plugin.loaded = true;
|
||||||
}
|
}
|
||||||
} catch (error)
|
} catch (error)
|
||||||
|
|
@ -72,10 +101,14 @@ export class PluginManager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadAll ()
|
async reloadAll (ctx: { setProgress: (progress: number, state: string) => void; })
|
||||||
{
|
{
|
||||||
this.hooks = new GameflowHooks();
|
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 ()
|
async cleanup ()
|
||||||
|
|
@ -83,8 +116,11 @@ export class PluginManager
|
||||||
await Promise.all(Object.values(this.plugins).filter(p => p.loaded && p.plugin.cleanup).map(async p =>
|
await Promise.all(Object.values(this.plugins).filter(p => p.loaded && p.plugin.cleanup).map(async p =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
if (p.loaded)
|
||||||
{
|
{
|
||||||
await p.plugin.cleanup!();
|
await p.plugin.cleanup!();
|
||||||
|
}
|
||||||
} catch (error)
|
} catch (error)
|
||||||
{
|
{
|
||||||
console.log("Error for plugin", p.description.name, "while cleaning up");
|
console.log("Error for plugin", p.description.name, "while cleaning up");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { plugins } from "../app";
|
import { plugins, taskQueue } from "../app";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { toggleElementInConfig } from "@/bun/utils";
|
import { toggleElementInConfig } from "@/bun/utils";
|
||||||
|
import ReloadPluginsJob from "../jobs/reload-plugins-job";
|
||||||
|
|
||||||
export default new Elysia({ prefix: '/plugins' })
|
export default new Elysia({ prefix: '/plugins' })
|
||||||
.get('/', async () =>
|
.get('/', async () =>
|
||||||
|
|
@ -15,19 +16,31 @@ export default new Elysia({ prefix: '/plugins' })
|
||||||
description: p.description.description,
|
description: p.description.description,
|
||||||
source: p.source,
|
source: p.source,
|
||||||
version: p.description.version,
|
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;
|
return plugin;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
.get('/:id', async ({ params: { id } }) =>
|
||||||
|
{
|
||||||
|
const plugin = plugins.plugins[id];
|
||||||
|
return plugin.description;
|
||||||
|
})
|
||||||
.post('/:id', async ({ params: { id }, body: { enabled } }) =>
|
.post('/:id', async ({ params: { id }, body: { enabled } }) =>
|
||||||
{
|
{
|
||||||
const plugin = plugins.plugins[id];
|
const plugin = plugins.plugins[id];
|
||||||
if (plugin)
|
if (plugin)
|
||||||
{
|
{
|
||||||
|
if (plugin.description.canDisable === false)
|
||||||
|
{
|
||||||
|
return status("Forbidden");
|
||||||
|
}
|
||||||
plugin.enabled = enabled;
|
plugin.enabled = enabled;
|
||||||
toggleElementInConfig('disabledPlugins', plugin.description.name, enabled);
|
toggleElementInConfig('disabledPlugins', plugin.description.name, enabled);
|
||||||
plugins.reloadAll();
|
await taskQueue.enqueue(ReloadPluginsJob.id, new ReloadPluginsJob());
|
||||||
} else
|
} else
|
||||||
{
|
{
|
||||||
return status("Not Found");
|
return status("Not Found");
|
||||||
|
|
|
||||||
|
|
@ -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 xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json';
|
||||||
import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/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 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";
|
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema";
|
||||||
|
|
||||||
export default async function register (pluginManager: PluginManager)
|
export default async function register (pluginManager: PluginManager)
|
||||||
{
|
{
|
||||||
|
|
||||||
const plugins: (PluginDescriptionType & { main: string; load: () => Promise<any>; })[] = [
|
const plugins: (PluginDescriptionType & { main: string; load: () => Promise<any>; })[] = [
|
||||||
{ ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') },
|
{ ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') },
|
||||||
{ ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') },
|
{ ...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') },
|
{ ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') },
|
||||||
{ ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') },
|
{ ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') },
|
||||||
{ ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') },
|
{ ...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();
|
const file = await pluginPackage.load();
|
||||||
if (file.default && typeof file.default === 'function')
|
if (file.default && typeof file.default === 'function')
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export const games = sqliteTable('games', {
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
ra_id: integer('ra_id').unique(),
|
ra_id: integer('ra_id').unique(),
|
||||||
path_fs: text("path_fs"),
|
path_fs: text("path_fs"),
|
||||||
|
main_glob: text("main_glob"),
|
||||||
last_played: integer("last_played", { mode: 'timestamp' }),
|
last_played: integer("last_played", { mode: 'timestamp' }),
|
||||||
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
|
created_at: integer("created_at", { mode: 'timestamp' }).default(sql`(unixepoch())`).notNull(),
|
||||||
metadata: text("metadata", { mode: 'json' }).default(sql`'{}'`).$type<{
|
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(),
|
platform_id: integer("platform_id").references(() => platforms.id, { onUpdate: 'cascade' }).notNull(),
|
||||||
cover: blob("cover", { mode: 'buffer' }),
|
cover: blob("cover", { mode: 'buffer' }),
|
||||||
cover_type: text('type'),
|
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 }) => ({
|
export const gamesRelations = relations(games, ({ many, one }) => ({
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@
|
||||||
import * as appSchema from '@schema/app';
|
import * as appSchema from '@schema/app';
|
||||||
import * as emulatorSchema from "@schema/emulators";
|
import * as emulatorSchema from "@schema/emulators";
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { db, emulatorsDb } from '../app';
|
import { db, emulatorsDb, plugins } from '../app';
|
||||||
import { cores } from '../emulatorjs/emulatorjs';
|
import { cores } from '../emulatorjs/emulatorjs';
|
||||||
import { SERVER_URL } from '@/shared/constants';
|
import { SERVER_URL } from '@/shared/constants';
|
||||||
import { findExecsByName } from '../games/services/launchGameService';
|
|
||||||
import { host } from '@/bun/utils/host';
|
import { host } from '@/bun/utils/host';
|
||||||
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
|
import { findEmulatorPluginIntegration } from '../store/services/emulatorsService';
|
||||||
|
|
||||||
|
|
@ -54,7 +53,18 @@ export async function getRelevantEmulators ()
|
||||||
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
const groupedEmulators = Map.groupBy(emulators, ({ emulator }) => emulator);
|
||||||
const finalEmulators = await Promise.all(Array.from(groupedEmulators.entries()).map(async ([emulator, system_slug]) =>
|
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;
|
let platform: number | null | undefined = null;
|
||||||
const validSystemSlug = system_slug.find(s => s.system);
|
const validSystemSlug = system_slug.find(s => s.system);
|
||||||
|
|
@ -75,7 +85,7 @@ export async function getRelevantEmulators ()
|
||||||
gameCount: 0,
|
gameCount: 0,
|
||||||
isCritical: false,
|
isCritical: false,
|
||||||
validSources: execPaths,
|
validSources: execPaths,
|
||||||
integrations: findEmulatorPluginIntegration(emulator, execPaths)
|
integrations
|
||||||
};
|
};
|
||||||
|
|
||||||
return em;
|
return em;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { SettingsSchema } from "@shared/constants";
|
import { SettingsSchema } from "@shared/constants";
|
||||||
import Elysia, { status } from "elysia";
|
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 fs from 'node:fs/promises';
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { InstallJob } from "../jobs/install-job";
|
import { InstallJob } from "../jobs/install-job";
|
||||||
import { move } from "fs-extra";
|
import { move } from "fs-extra";
|
||||||
import { getRelevantEmulators } from "./services";
|
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' })
|
export const settings = new Elysia({ prefix: '/api/settings' })
|
||||||
.get('/emulators/automatic', async () =>
|
.get('/emulators/automatic', async () =>
|
||||||
|
|
@ -77,18 +80,59 @@ export const settings = new Elysia({ prefix: '/api/settings' })
|
||||||
drive: z.string().optional()
|
drive: z.string().optional()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.get("/:id", async ({ params: { id } }) =>
|
.get("local/:id", async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
const value = config.get(id);
|
const value = config.get(id);
|
||||||
return { value: value };
|
return { value: value };
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||||
}).post('/:id',
|
}).post('local/:id',
|
||||||
async ({ params: { id }, body: { value }, }) =>
|
async ({ params: { id }, body: { value }, }) =>
|
||||||
{
|
{
|
||||||
config.set(id, value);
|
config.set(id, value);
|
||||||
}, {
|
}, {
|
||||||
params: z.object({ id: z.keyof(SettingsSchema) }),
|
params: z.object({ id: z.keyof(SettingsSchema) }),
|
||||||
body: z.object({ value: z.any() }),
|
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() })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,7 @@
|
||||||
import { EmulatorDownloadInfoSchema, EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
|
import { EmulatorDownloadInfoType, EmulatorPackageType, ScoopPackageSchema } from "@/shared/constants";
|
||||||
import { config, emulatorsDb, plugins } from "../../app";
|
import { config, plugins } from "../../app";
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
|
||||||
import { findExecs } from "../../games/services/launchGameService";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { getOrCached, getOrCachedGithubRelease } from "../../cache";
|
import { getOrCached, getOrCachedGithubRelease } from "../../cache";
|
||||||
import path from "node:path";
|
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[]
|
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);
|
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)
|
export async function getEmulatorDownload (emulator: EmulatorPackageType, source: string)
|
||||||
{
|
{
|
||||||
if (!emulator.downloads) throw new Error("Emulator has no downloads");
|
if (!emulator.downloads) throw new Error("Emulator has no downloads");
|
||||||
|
|
|
||||||
|
|
@ -6,74 +6,9 @@ import path from "node:path";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import * as emulatorSchema from '@schema/emulators';
|
import * as emulatorSchema from '@schema/emulators';
|
||||||
import { shuffleInPlace } from "@/bun/utils";
|
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 ()
|
export function getStoreRootFolder ()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,18 @@
|
||||||
|
|
||||||
import Elysia, { status } from "elysia";
|
import Elysia, { status } from "elysia";
|
||||||
import { config, db, taskQueue } from "../app";
|
import { config, db, plugins, taskQueue } from "../app";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { EmulatorDownloadInfoSchema, StoreGameSchema } from "@/shared/constants";
|
import { EmulatorDownloadInfoSchema } from "@/shared/constants";
|
||||||
import { findExecsByName } from "../games/services/launchGameService";
|
|
||||||
import * as appSchema from '@schema/app';
|
import * as appSchema from '@schema/app';
|
||||||
import z from "zod";
|
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 { getPlatformsApiPlatformsGet } from "@/clients/romm";
|
||||||
import { CACHE_KEYS, getOrCached } from "../cache";
|
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 { EmulatorDownloadJob } from "../jobs/emulator-download-job";
|
||||||
import { convertStoreEmulatorToFrontend, findEmulatorPluginIntegration, getEmulatorDownload, getExistingStoreEmulatorDownload } from "./services/emulatorsService";
|
|
||||||
import { BiosDownloadJob } from "../jobs/bios-download-job";
|
import { BiosDownloadJob } from "../jobs/bios-download-job";
|
||||||
|
import { findEmulatorPluginIntegration } from "./services/emulatorsService";
|
||||||
|
|
||||||
export const store = new Elysia({ prefix: '/api/store' })
|
export const store = new Elysia({ prefix: '/api/store' })
|
||||||
.get('/emulators', async ({ query }) =>
|
.get('/emulators', async ({ query }) =>
|
||||||
|
|
@ -23,28 +22,14 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
console.error(e);
|
console.error(e);
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
const emulatesParsed = await getAllStoreEmulatorPackages();
|
|
||||||
let frontEndEmulators = await Promise.all(emulatesParsed
|
|
||||||
.filter(e =>
|
|
||||||
{
|
|
||||||
if (!e.os.includes(process.platform as any)) return false;
|
|
||||||
if (query.search)
|
|
||||||
{
|
|
||||||
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;
|
let frontEndEmulators: FrontEndEmulator[] = [];
|
||||||
}
|
await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators, search: query.search });
|
||||||
return true;
|
|
||||||
})
|
await Promise.all(frontEndEmulators.map(async e =>
|
||||||
.map(async (emulator) =>
|
|
||||||
{
|
{
|
||||||
const systems = await buildStoreFrontendEmulatorSystems(emulator);
|
const gameCounts = e.systems.map((s) =>
|
||||||
const gameCounts = await Promise.all(systems.map(async (s) =>
|
|
||||||
{
|
{
|
||||||
const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id));
|
const romPlatform = rommPlatforms?.find(p => p.slug === (s.romm_slug ?? s.id));
|
||||||
if (romPlatform)
|
if (romPlatform)
|
||||||
|
|
@ -54,10 +39,14 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
}));
|
});
|
||||||
|
|
||||||
const gameCount = gameCounts.reduce((a, c) => a + c);
|
const execPaths: EmulatorSourceEntryType[] = [];
|
||||||
return convertStoreEmulatorToFrontend(emulator, gameCount, systems);
|
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)
|
if (query.missing)
|
||||||
|
|
@ -98,25 +87,31 @@ export const store = new Elysia({ prefix: '/api/store' })
|
||||||
})
|
})
|
||||||
.get('/games/featured', async () =>
|
.get('/games/featured', async () =>
|
||||||
{
|
{
|
||||||
const response = await fetch('https://cdn.jsdelivr.net/gh/dragoonDorise/EmuDeck/store/featured.json');
|
const games: FrontEndGameTypeDetailed[] = [];
|
||||||
const games = await z.object({ featured: z.array(StoreGameSchema) }).parseAsync(await response.json());
|
await plugins.hooks.store.fetchFeaturedGames.promise({ games });
|
||||||
return Promise.all(games.featured.map(async g =>
|
|
||||||
|
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);
|
if (localGame) return convertLocalToFrontendDetailed(localGame);
|
||||||
return convertStoreToFrontendDetailed(g.system, g.title, g);
|
return g;
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.get('/stats', async () =>
|
.get('/stats', async () =>
|
||||||
{
|
{
|
||||||
const emulatesParsed = await getAllStoreEmulatorPackages();
|
let frontEndEmulators: FrontEndEmulator[] = [];
|
||||||
const storeEmulatorCount = emulatesParsed.filter(e => e.os.includes(process.platform as any)).length;
|
await plugins.hooks.store.fetchEmulators.promise({ emulators: frontEndEmulators });
|
||||||
|
const storeEmulatorCount = frontEndEmulators.length;
|
||||||
const gameCount = await db.$count(appSchema.games);
|
const gameCount = await db.$count(appSchema.games);
|
||||||
return {
|
return {
|
||||||
storeEmulatorCount,
|
storeEmulatorCount,
|
||||||
gameCount
|
gameCount
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
.get('/media/*', async ({ params }) =>
|
||||||
|
{
|
||||||
|
return Bun.file(path.join(getStoreFolder(), params["*"]));
|
||||||
|
})
|
||||||
.get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) =>
|
.get('/screenshot/emulator/:id/:name', async ({ params: { id, name } }) =>
|
||||||
{
|
{
|
||||||
return Bun.file(path.join(getStoreFolder(), "media", "screenshots", 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() }) })
|
{ params: z.object({ id: z.string(), name: z.string() }) })
|
||||||
.get('/emulator/:id/update', async ({ params: { id } }) =>
|
.get('/emulator/:id/update', async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
return plugins.hooks.store.fetchDownload.promise({ id });
|
||||||
const downloadInfo = await getExistingStoreEmulatorDownload(emulatorPackage!);
|
|
||||||
return downloadInfo;
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()])
|
response: z.union([z.intersection(EmulatorDownloadInfoSchema, z.object({ hasUpdate: z.boolean() })), z.undefined()])
|
||||||
})
|
})
|
||||||
.get('/emulator/:id', async ({ params: { id } }) =>
|
.get('/emulator/:id', async ({ params: { id } }) =>
|
||||||
{
|
{
|
||||||
const emulatorPackage = await getStoreEmulatorPackage(id);
|
return plugins.hooks.store.fetchEmulator.promise({ 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;
|
|
||||||
}, { params: z.object({ id: z.string() }) })
|
}, { params: z.object({ id: z.string() }) })
|
||||||
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
|
.post('/install/emulator/:id/:source', async ({ params: { source, id }, body: { isUpdate } }) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import Elysia from "elysia";
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { cachePath, config, events } from "./app";
|
import { cachePath, config, events, taskQueue } from "./app";
|
||||||
import { isSteamDeck, openExternal } from "../utils";
|
import { isSteamDeck, openExternal } from "../utils";
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import buildNotificationsStream from "./notifications";
|
import buildNotificationsStream from "./notifications";
|
||||||
|
|
@ -12,6 +12,22 @@ import { getDevices, getDevicesCurated } from "./drives";
|
||||||
import getFolderSize from "get-folder-size";
|
import getFolderSize from "get-folder-size";
|
||||||
import si from 'systeminformation';
|
import si from 'systeminformation';
|
||||||
import { getStoreFolder } from "./store/services/gamesService";
|
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' })
|
export const system = new Elysia({ prefix: '/api/system' })
|
||||||
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
|
||||||
|
|
@ -60,13 +76,25 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
set.headers["cache-control"] = 'no-cache';
|
set.headers["cache-control"] = 'no-cache';
|
||||||
set.headers['connection'] = 'keep-alive';
|
set.headers['connection'] = 'keep-alive';
|
||||||
return new Response(buildNotificationsStream());
|
return new Response(buildNotificationsStream());
|
||||||
|
})
|
||||||
|
.get('/notifications/all', ({ }) =>
|
||||||
|
{
|
||||||
|
|
||||||
})
|
})
|
||||||
.ws('/info/system', {
|
.ws('/info/system', {
|
||||||
response: z.discriminatedUnion('type', [
|
response: z.discriminatedUnion('type', [
|
||||||
z.object({ type: z.literal('info'), data: SystemInfoSchema }),
|
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)
|
async open (ws)
|
||||||
|
{
|
||||||
|
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 battery = await si.battery();
|
||||||
const wifi = await si.wifiConnections();
|
const wifi = await si.wifiConnections();
|
||||||
|
|
@ -79,10 +107,33 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
bluetoothDevices: bluetooth
|
bluetoothDevices: bluetooth
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
};
|
||||||
|
startInfo();
|
||||||
|
|
||||||
const handleFocus = () => ws.send({ type: 'focus' });
|
const handleFocus = () => ws.send({ type: 'focus' });
|
||||||
events.on('focus', handleFocus);
|
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 () =>
|
(ws.data as any).observer = setInterval(async () =>
|
||||||
{
|
{
|
||||||
const battery = await si.battery();
|
const battery = await si.battery();
|
||||||
|
|
@ -209,4 +260,8 @@ export const system = new Elysia({ prefix: '/api/system' })
|
||||||
await openExternal(url);
|
await openExternal(url);
|
||||||
}, {
|
}, {
|
||||||
body: z.object({ url: z.string() })
|
body: z.object({ url: z.string() })
|
||||||
|
})
|
||||||
|
.get('/update', async () =>
|
||||||
|
{
|
||||||
|
return checkUpdate();
|
||||||
});
|
});
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
|
||||||
|
|
||||||
|
import { and } from 'drizzle-orm';
|
||||||
import EventEmitter from 'node:events';
|
import EventEmitter from 'node:events';
|
||||||
import z from 'zod';
|
import z, { any } from 'zod';
|
||||||
|
|
||||||
export class TaskQueue
|
export class TaskQueue
|
||||||
{
|
{
|
||||||
|
|
@ -121,29 +122,29 @@ export interface EventsList
|
||||||
queued: [e: BaseEvent];
|
queued: [e: BaseEvent];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseEvent
|
export interface BaseEvent
|
||||||
{
|
{
|
||||||
id: string;
|
id: string;
|
||||||
job: IPublicJob<any, string, any>;
|
job: IPublicJob<any, string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorEvent extends BaseEvent
|
export interface ErrorEvent extends BaseEvent
|
||||||
{
|
{
|
||||||
error: unknown;
|
error: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AbortEvent extends BaseEvent
|
export interface AbortEvent extends BaseEvent
|
||||||
{
|
{
|
||||||
reason?: any;
|
reason?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProgressEvent extends BaseEvent
|
export interface ProgressEvent extends BaseEvent
|
||||||
{
|
{
|
||||||
progress: number;
|
progress: number;
|
||||||
state?: string;
|
state?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompletedEvent extends BaseEvent
|
export interface CompletedEvent extends BaseEvent
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,52 @@
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { GameflowHooks } from "../api/hooks/app";
|
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({
|
export const PluginContextSchema = z.object({
|
||||||
hooks: z.instanceof(GameflowHooks)
|
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({
|
export const PluginDescriptionSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
displayName: z.string(),
|
displayName: z.string(),
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
icon: z.url().optional(),
|
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({
|
export const PluginSchema = z.object({
|
||||||
setup: z.function().output(z.promise(z.void())).optional(),
|
load: z.function().input([PluginLoadingContextSchema]).output(z.promise(z.void())),
|
||||||
load: z.function().input([PluginContextSchema]).output(z.void()),
|
cleanup: z.function().output(z.promise(z.void())).optional(),
|
||||||
onBeforeReload: z.function().input([PluginContextSchema]).output(z.void()).optional(),
|
settingsSchema: z.instanceof(z.ZodObject).optional(),
|
||||||
cleanup: z.function().output(z.promise(z.void())).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<typeof PluginSchema>;
|
export type PluginType<T extends Record<string, any> = Record<string, any>> = Omit<z.infer<typeof PluginSchema>, "load" | 'settingsMigrations'> & {
|
||||||
|
load: (ctx: PluginLoadingContextType<T>) => Promise<void>;
|
||||||
|
settingsMigrations?: Record<string, (conf: Conf<T>) => void>;
|
||||||
|
};
|
||||||
export type PluginContextType = z.infer<typeof PluginContextSchema>;
|
export type PluginContextType = z.infer<typeof PluginContextSchema>;
|
||||||
|
export type PluginLoadingContextType<TSettings extends Record<string, any> = Record<string, any>> = z.infer<typeof PluginLoadingContextSchema> & {
|
||||||
|
config: Conf<TSettings>;
|
||||||
|
};
|
||||||
export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
|
export type PluginDescriptionType = z.infer<typeof PluginDescriptionSchema>;
|
||||||
|
|
||||||
export const ActiveGameSchema = z.object({
|
export const ActiveGameSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ export const focusQueue: string[] = [];
|
||||||
|
|
||||||
export default function App (data: { children: any; })
|
export default function App (data: { children: any; })
|
||||||
{
|
{
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const focusMap = new Map<number, string>();
|
const focusMap = new Map<number, string>();
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { SystemInfoContext } from "../scripts/contexts";
|
import { SystemInfoContext } from "../scripts/contexts";
|
||||||
import { systemApi } from "../scripts/clientApi";
|
import { systemApi } from "../scripts/clientApi";
|
||||||
import { SystemInfoType } from "@/shared/constants";
|
import { SystemInfoType } from "@/shared/constants";
|
||||||
|
import LoadingScreen from "./LoadingScreen";
|
||||||
|
|
||||||
export default function AppCommunication (data: { children: any; })
|
export default function AppCommunication (data: { children: any; })
|
||||||
{
|
{
|
||||||
|
|
||||||
const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>();
|
const [systemInfo, setSystemInfo] = useState<SystemInfoType | undefined>();
|
||||||
|
const [loadingInfo, setLoadingInfo] = useState<string | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const loadingProgressBarRef = useRef<HTMLProgressElement>(null);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
const sub = systemApi.api.system.info.system.subscribe();
|
const sub = systemApi.api.system.info.system.subscribe();
|
||||||
|
|
@ -20,14 +24,32 @@ export default function AppCommunication (data: { children: any; })
|
||||||
case "focus":
|
case "focus":
|
||||||
window.focus();
|
window.focus();
|
||||||
break;
|
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";
|
document.documentElement.dataset.loaded = "true";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <SystemInfoContext value={systemInfo}>
|
return <SystemInfoContext value={systemInfo}>
|
||||||
{data.children}
|
{loading ?
|
||||||
|
<LoadingScreen>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="loading loading-spinner loading-xl"></span>
|
||||||
|
{loadingInfo}
|
||||||
|
</div>
|
||||||
|
<progress ref={loadingProgressBarRef} className="progress w-[20vw]" value={0} max="100"></progress>
|
||||||
|
</div>
|
||||||
|
</LoadingScreen>
|
||||||
|
: data.children}
|
||||||
</SystemInfoContext>;
|
</SystemInfoContext>;
|
||||||
}
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { JSX } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import useActiveControl from "../scripts/gamepads";
|
import useActiveControl from "../scripts/gamepads";
|
||||||
import { oneShot } from "../scripts/audio/audio";
|
import { oneShot } from "../scripts/audio/audio";
|
||||||
|
import ImageWithFallbacks from "./ImageWithFallbacks";
|
||||||
|
|
||||||
export function GameCardSkeleton ()
|
export function GameCardSkeleton ()
|
||||||
{
|
{
|
||||||
|
|
@ -21,8 +22,8 @@ export function GameCardSkeleton ()
|
||||||
export interface GameCardParams extends FocusParams
|
export interface GameCardParams extends FocusParams
|
||||||
{
|
{
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string | JSX.Element;
|
subtitle?: string | JSX.Element;
|
||||||
preview?: string | JSX.Element | ((p: { focused: boolean; }) => JSX.Element);
|
preview?: string | JSX.Element | URL[] | ((p: { focused: boolean; }) => JSX.Element);
|
||||||
srcset?: string;
|
srcset?: string;
|
||||||
focusKey: string;
|
focusKey: string;
|
||||||
index: number;
|
index: number;
|
||||||
|
|
@ -49,6 +50,21 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
});
|
});
|
||||||
const { isPointer } = useActiveControl();
|
const { isPointer } = useActiveControl();
|
||||||
|
|
||||||
|
let preview: any = undefined;
|
||||||
|
if (typeof data.preview === "string")
|
||||||
|
{
|
||||||
|
preview = <img draggable={false} srcSet={data.srcset} className={classNames("object-cover aspect-3/4", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>;
|
||||||
|
} else if (Array.isArray(data.preview))
|
||||||
|
{
|
||||||
|
preview = <ImageWithFallbacks src={data.preview} draggable={false} className={classNames("object-cover aspect-3/4 w-full h-full", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} />;
|
||||||
|
} else if (typeof data.preview === 'function')
|
||||||
|
{
|
||||||
|
preview = data.preview({ focused });
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
preview = data.preview;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
id={`game-entry-${data.id}`}
|
id={`game-entry-${data.id}`}
|
||||||
|
|
@ -76,11 +92,7 @@ export default function CardElement (data: GameCardParams & InteractParams)
|
||||||
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
|
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
|
||||||
classNames({ "h-full": typeof data.preview === "string" })
|
classNames({ "h-full": typeof data.preview === "string" })
|
||||||
)}>
|
)}>
|
||||||
{typeof data.preview === "string" ? (
|
{preview}
|
||||||
<img draggable={false} srcSet={data.srcset} className={classNames("object-cover aspect-3/4", data.previewClassName, { "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
|
|
||||||
) : (
|
|
||||||
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2 z-2">
|
<div className="h-0 flex pr-2 justify-end items-center sm:gap-1 md:gap-2 z-2">
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ export interface GameMetaExtra extends GameMeta
|
||||||
function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams)
|
function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusParams & InteractParams)
|
||||||
{
|
{
|
||||||
let preview: GameCardParams['preview'] = data.game.preview;
|
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) =>
|
const handleAction = (ctx: InteractParamsArgs) =>
|
||||||
|
|
@ -40,7 +40,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
|
||||||
focusKey={data.game.focusKey}
|
focusKey={data.game.focusKey}
|
||||||
data-index={data.i}
|
data-index={data.i}
|
||||||
title={data.game.title}
|
title={data.game.title}
|
||||||
subtitle={data.game.subtitle ?? ""}
|
subtitle={data.game.subtitle}
|
||||||
srcset={data.game.previewSrcset}
|
srcset={data.game.previewSrcset}
|
||||||
onFocus={(focusKey, node, details) =>
|
onFocus={(focusKey, node, details) =>
|
||||||
{
|
{
|
||||||
|
|
@ -69,8 +69,6 @@ export function CardList (data: {
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusKey: data.id,
|
focusKey: data.id,
|
||||||
forceFocus: true,
|
|
||||||
autoRestoreFocus: true,
|
|
||||||
focusable: data.games.length > 0,
|
focusable: data.games.length > 0,
|
||||||
preferredChildFocusKey: data.focus
|
preferredChildFocusKey: data.focus
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ export default function CollectionList (data: {
|
||||||
id: `${g.id.source}@${g.id.id}`,
|
id: `${g.id.source}@${g.id.id}`,
|
||||||
title: g.name,
|
title: g.name,
|
||||||
focusKey: `collection-${g.id}`,
|
focusKey: `collection-${g.id}`,
|
||||||
subtitle: "",
|
|
||||||
previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
|
previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
|
||||||
badges: [
|
badges: [
|
||||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||||
|
|
@ -46,7 +45,7 @@ export default function CollectionList (data: {
|
||||||
],
|
],
|
||||||
} satisfies GameMetaExtra))}
|
} satisfies GameMetaExtra))}
|
||||||
onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect}
|
onSelectGame={data.onSelect ? data.onSelect : handleDefaultSelect}
|
||||||
onGameFocus={(id, node, details) =>
|
onFocus={(id, node, details) =>
|
||||||
{
|
{
|
||||||
data.setBackground(
|
data.setBackground(
|
||||||
`https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`,
|
`https://picsum.photos/id/${10 + (id ?? 0)}/100/100.webp?blur=10`,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { GameListFilterSchema, GameListFilterType } from '@/shared/constants';
|
||||||
import { HandleGoBack } from '../scripts/utils';
|
import { HandleGoBack } from '../scripts/utils';
|
||||||
import LoadingCardList from './LoadingCardList';
|
import LoadingCardList from './LoadingCardList';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { useNavigate, useRouter } from '@tanstack/react-router';
|
||||||
import SelectMenu from './SelectMenu';
|
import SelectMenu from './SelectMenu';
|
||||||
import { RoundButton } from './RoundButton';
|
import { RoundButton } from './RoundButton';
|
||||||
|
|
@ -41,7 +41,6 @@ export interface CollectionsDetailParams
|
||||||
export function CollectionsDetail (data: CollectionsDetailParams)
|
export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
{
|
{
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filterValues, setFilterValues] = useState<FrontEndFilterLists>();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const finalFilter = { ...data.localFilter, ...data.filters };
|
const finalFilter = { ...data.localFilter, ...data.filters };
|
||||||
const focusKey = `game-list-${data.id}`;
|
const focusKey = `game-list-${data.id}`;
|
||||||
|
|
@ -50,6 +49,8 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
preferredChildFocusKey: `${focusKey}-list`
|
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]);
|
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: (e) => HandleGoBack(router, e) }], [router]);
|
||||||
|
|
||||||
const handleScroll: FocusParams['onFocus'] = (cardId, node, details) =>
|
const handleScroll: FocusParams['onFocus'] = (cardId, node, details) =>
|
||||||
|
|
@ -79,7 +80,6 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
||||||
<GameList
|
<GameList
|
||||||
key={`${data.id}-${JSON.stringify(finalFilter)}`}
|
key={`${data.id}-${JSON.stringify(finalFilter)}`}
|
||||||
grid
|
grid
|
||||||
setFilterValues={setFilterValues}
|
|
||||||
filters={finalFilter}
|
filters={finalFilter}
|
||||||
onFocus={handleScroll}
|
onFocus={handleScroll}
|
||||||
focus={data.focus}
|
focus={data.focus}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import { Gamepad2, HardDrive, Store } from "lucide-react";
|
import { CloudSync, Gamepad2, HardDrive, MonitorPlay, Store, Terminal } from "lucide-react";
|
||||||
|
|
||||||
export const sourceIconMap: Record<string, any> = {
|
export const sourceIconMap: Record<string, any> = {
|
||||||
store: <Store />,
|
store: <Store />,
|
||||||
local: <HardDrive />,
|
local: <HardDrive />,
|
||||||
romm: <Gamepad2 />
|
romm: <Gamepad2 />
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const pluginCategoryIcons: Record<string, any> = {
|
||||||
|
saves: <CloudSync />,
|
||||||
|
sources: <Gamepad2 />,
|
||||||
|
launchers: <Terminal />,
|
||||||
|
emulators: <MonitorPlay />
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pluginCategoryPriorities: Record<string, number> = {
|
||||||
|
saves: 100,
|
||||||
|
sources: 90,
|
||||||
|
launchers: 80,
|
||||||
|
emulators: 60
|
||||||
|
};
|
||||||
|
|
@ -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 } });
|
router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let subtitle: any = undefined;
|
||||||
|
if (data.game.path_platform_cover)
|
||||||
|
{
|
||||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
|
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);
|
||||||
platformUrl.searchParams.set('width', "64");
|
platformUrl.searchParams.set('width', "64");
|
||||||
const subtitle = <div className="flex gap-1 items-center">
|
subtitle = <div className="flex gap-1 items-center">
|
||||||
{!!data.game.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
{!!data.game.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
||||||
<p className="opacity-80">{data.game.platform_display_name}</p>
|
<p className="opacity-80">{data.game.platform_display_name}</p>
|
||||||
</div>;
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_cover}`);
|
const previewUrls = data.game.path_covers.map(c =>
|
||||||
previewUrl.searchParams.delete('ts');
|
{
|
||||||
previewUrl.searchParams.set('width', "640");
|
const url = new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||||
|
url.searchParams.delete('ts');
|
||||||
|
url.searchParams.set('width', "640");
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
|
||||||
const badges: JSX.Element[] = [];
|
const badges: JSX.Element[] = [];
|
||||||
|
|
||||||
|
|
@ -53,7 +61,7 @@ export default function FrontEndGameCard (data: { index: number, game: FrontEndG
|
||||||
badges={badges}
|
badges={badges}
|
||||||
onFocus={data.onFocus}
|
onFocus={data.onFocus}
|
||||||
onAction={(e) => data.onAction ? data.onAction(e) : handleDefaultSelect(data.game.id, data.game.source, data.game.source_id)}
|
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 ?? ""}
|
title={data.game.name ?? ""}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)}
|
focusKey={FOCUS_KEYS.GAME_CARD(data.game.id)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { GameMetaExtra, CardList } from "./CardList";
|
import { GameMetaExtra, CardList } from "./CardList";
|
||||||
import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants";
|
import { DefaultRommStaleTime, GameListFilterType, RPC_URL } from "@shared/constants";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
@ -19,7 +19,6 @@ export interface GameListParams extends FocusParams
|
||||||
className?: string;
|
className?: string;
|
||||||
finalElement?: JSX.Element;
|
finalElement?: JSX.Element;
|
||||||
saveChildFocus?: "session" | "local";
|
saveChildFocus?: "session" | "local";
|
||||||
setFilterValues?: (filters: FrontEndFilterLists) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameList (data: GameListParams)
|
export function GameList (data: GameListParams)
|
||||||
|
|
@ -37,7 +36,7 @@ export function GameList (data: GameListParams)
|
||||||
try
|
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 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);
|
const previewUrl = blur ? coverUrl : (screenshotUrl ?? coverUrl);
|
||||||
previewUrl.searchParams.delete('ts');
|
previewUrl.searchParams.delete('ts');
|
||||||
data.setBackground?.(previewUrl.href) ?? backgroundContext.setBackground(previewUrl.href);
|
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)
|
function handleDefaultSelect (g: FrontEndGameType)
|
||||||
{
|
{
|
||||||
navigator({ to: '/game/$source/$id', params: { id: String(g.source_id ?? g.id.id), source: g.source ?? g.id.source } });
|
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(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
const previewUrls = g.path_covers.map(c =>
|
||||||
previewUrl.searchParams.delete('ts');
|
{
|
||||||
|
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}`);
|
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");
|
platformUrl.searchParams.set('width', "64");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${g.id.source}@${g.id.id}`,
|
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 ?? "",
|
title: g.name ?? "",
|
||||||
subtitle: (
|
subtitle: (
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
{!!g.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
<img className="sm:hidden md:inline size-4" src={platformUrl?.href} />
|
||||||
<p className="opacity-80">{g.platform_display_name}</p>
|
<p className="opacity-80">{g.platform_display_name}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
previewUrl: previewUrl.href,
|
previewUrls: previewUrls,
|
||||||
badges: badges,
|
badges: badges,
|
||||||
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g),
|
onSelect: () => data.onGameSelect ? data.onGameSelect(g.id, g.source, g.source_id) : handleDefaultSelect(g),
|
||||||
onFocus: () => handleFocus(g.id, g.source, g.source_id)
|
onFocus: () => handleFocus(g.id, g.source, g.source_id)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import
|
||||||
BatteryWarning,
|
BatteryWarning,
|
||||||
Bell,
|
Bell,
|
||||||
Bluetooth,
|
Bluetooth,
|
||||||
|
CircleFadingArrowUp,
|
||||||
Clock,
|
Clock,
|
||||||
Settings,
|
Settings,
|
||||||
Wifi,
|
Wifi,
|
||||||
|
|
@ -31,6 +32,7 @@ import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
|
||||||
import { SystemInfoContext } from "../scripts/contexts";
|
import { SystemInfoContext } from "../scripts/contexts";
|
||||||
import { useRouter } from "@tanstack/react-router";
|
import { useRouter } from "@tanstack/react-router";
|
||||||
import { oneShot } from "../scripts/audio/audio";
|
import { oneShot } from "../scripts/audio/audio";
|
||||||
|
import { hasUpdateQuery } from "../scripts/queries/system";
|
||||||
|
|
||||||
function HeaderAvatar (data: {
|
function HeaderAvatar (data: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -83,6 +85,14 @@ export interface HeaderAccount
|
||||||
action?: () => void;
|
action?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UpdateStatus ()
|
||||||
|
{
|
||||||
|
const hasUnread = false;
|
||||||
|
return <div className={classNames("tooltip tooltip-bottom tooltip-warning p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })} data-tip="Update Available">
|
||||||
|
<CircleFadingArrowUp className="sm:size-4 md:size-8 text-warning" />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationStatus ()
|
function NotificationStatus ()
|
||||||
{
|
{
|
||||||
const hasUnread = false;
|
const hasUnread = false;
|
||||||
|
|
@ -249,13 +259,15 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
|
||||||
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
|
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
|
||||||
{
|
{
|
||||||
const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' });
|
const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' });
|
||||||
|
const { data: hasUpdate } = useQuery(hasUpdateQuery);
|
||||||
return <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
return <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
|
||||||
<FocusContext value={focusKey}>
|
<FocusContext value={focusKey}>
|
||||||
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
<div className="flex gap-2 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
|
||||||
<ClockStatus />
|
<ClockStatus />
|
||||||
<WiFiStatus />
|
<WiFiStatus />
|
||||||
<BluetoothStatus />
|
<BluetoothStatus />
|
||||||
<NotificationStatus />
|
<NotificationStatus />
|
||||||
|
{!!hasUpdate && hasUpdate >= 1 && <UpdateStatus />}
|
||||||
<BatteryStatus />
|
<BatteryStatus />
|
||||||
</div>
|
</div>
|
||||||
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
|
||||||
|
|
|
||||||
19
src/mainview/components/ImageWithFallbacks.tsx
Normal file
19
src/mainview/components/ImageWithFallbacks.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export default function ImageWithFallbacks (data: {
|
||||||
|
src: URL[];
|
||||||
|
draggable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) =>
|
||||||
|
{
|
||||||
|
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 <img draggable={data.draggable} className={data.className} src={data.src[0].href} data-index={0} onError={handleError}></img>;
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,6 @@ export default function LoadingCardList (data: { id: string, placeholderCount: n
|
||||||
ref={ref}
|
ref={ref}
|
||||||
title="Games"
|
title="Games"
|
||||||
id={`card-list-placeholder`}
|
id={`card-list-placeholder`}
|
||||||
save-child-focus="session"
|
|
||||||
className={twMerge("items-center justify-center-safe h-full",
|
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))]" :
|
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!',
|
'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!',
|
||||||
|
|
|
||||||
9
src/mainview/components/LoadingScreen.tsx
Normal file
9
src/mainview/components/LoadingScreen.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default function LoadingScreen (data: { children?: any; })
|
||||||
|
{
|
||||||
|
return <div className="absolute flex items-center gap-2 justify-center bg-base-300 w-screen h-screen z-100 font-semibold text-2xl text-shadow-lg">
|
||||||
|
<div className="absolute w-screen h-screen bg-radial from-base-100 to-base-300 -z-2"></div>
|
||||||
|
<div className="bg-noise"></div>
|
||||||
|
<div className="bg-dots"></div>
|
||||||
|
{data.children}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,36 @@ import { useNavigate } from "@tanstack/react-router";
|
||||||
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
|
import { DefaultRommStaleTime, RPC_URL } from "@shared/constants";
|
||||||
import { CardList, GameMetaExtra } from "./CardList";
|
import { CardList, GameMetaExtra } from "./CardList";
|
||||||
import { rommApi } from "../scripts/clientApi";
|
import { rommApi } from "../scripts/clientApi";
|
||||||
import { JSX, useMemo } from "react";
|
import { JSX, useMemo, useState } from "react";
|
||||||
import { HardDrive } from "lucide-react";
|
import { Gamepad2, HardDrive } from "lucide-react";
|
||||||
import { mobileCheck } from "../scripts/utils";
|
import { mobileCheck } from "../scripts/utils";
|
||||||
import { twMerge } from "tailwind-merge";
|
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 <div
|
||||||
|
className="flex p-6 bg-base-100 justify-center items-center aspect-square"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(
|
||||||
|
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
||||||
|
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
||||||
|
), url(https://picsum.photos/id/${10 + data.index}/100/100.webp?blur=10) center / cover`,
|
||||||
|
|
||||||
|
backgroundBlendMode: isMobile ? undefined : "screen",
|
||||||
|
boxShadow: isMobile ? undefined : 'inset 0 0 32px rgba(0,0,0,0.6)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img draggable={false} className={"not-mobile:drop-shadow-2xl in-focus:animate-rotate"}
|
||||||
|
onError={e => e.currentTarget.src = placeholder}
|
||||||
|
src={coverUrl.href}
|
||||||
|
>
|
||||||
|
</img>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
export function PlatformsList (data: {
|
export function PlatformsList (data: {
|
||||||
id: string,
|
id: string,
|
||||||
|
|
@ -17,7 +43,7 @@ export function PlatformsList (data: {
|
||||||
saveChildFocus?: "session" | "local";
|
saveChildFocus?: "session" | "local";
|
||||||
} & FocusParams)
|
} & FocusParams)
|
||||||
{
|
{
|
||||||
const isMobile = mobileCheck();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: platforms } = useSuspenseQuery(
|
const { data: platforms } = useSuspenseQuery(
|
||||||
{
|
{
|
||||||
|
|
@ -44,37 +70,19 @@ export function PlatformsList (data: {
|
||||||
badges.push(<span className="flex items-center justify-center sm:size-3 md:size-6 m-1 md:text-2xl font-semibold font-boldrounded-full">{g.game_count}</span>);
|
badges.push(<span className="flex items-center justify-center sm:size-3 md:size-6 m-1 md:text-2xl font-semibold font-boldrounded-full">{g.game_count}</span>);
|
||||||
if (g.hasLocal)
|
if (g.hasLocal)
|
||||||
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
badges.push(<HardDrive className="sm:size-4 md:size-8 m-1" />);
|
||||||
const coverUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
|
||||||
coverUrl.searchParams.set('width', "320");
|
|
||||||
const entry: GameMetaExtra = {
|
const entry: GameMetaExtra = {
|
||||||
id: g.slug,
|
id: g.slug,
|
||||||
focusKey: g.slug,
|
focusKey: g.slug,
|
||||||
title: g.name,
|
title: g.name,
|
||||||
subtitle: g.family_name ?? "",
|
subtitle: g.family_name ?? undefined,
|
||||||
previewUrl: "",
|
previewUrls: "",
|
||||||
badges,
|
badges,
|
||||||
onFocus: () => data.setBackground(
|
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`,
|
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),
|
onSelect: () => data.onSelect ? data.onSelect(g.id.source, g.id.id) : handleDefaultSelect(g.id.source, g.id.id),
|
||||||
preview:
|
preview: () => <Preview index={i} pathCover={g.path_cover} />
|
||||||
() => <div
|
|
||||||
className="flex p-6 bg-base-100 justify-center"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(
|
|
||||||
color-mix(in srgb, var(--color-base-content) 60%, transparent),
|
|
||||||
color-mix(in srgb, var(--color-base-300) 60%, transparent)
|
|
||||||
), url(https://picsum.photos/id/${10 + i}/100/100.webp?blur=10) center / cover`,
|
|
||||||
|
|
||||||
backgroundBlendMode: isMobile ? undefined : "screen",
|
|
||||||
boxShadow: isMobile ? undefined : 'inset 0 0 32px rgba(0,0,0,0.6)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img draggable={false} className={"not-mobile:drop-shadow-2xl in-focus:animate-rotate"}
|
|
||||||
src={coverUrl.href}
|
|
||||||
></img>
|
|
||||||
</div>
|
|
||||||
,
|
|
||||||
};
|
};
|
||||||
return entry;
|
return entry;
|
||||||
}), [platforms]);
|
}), [platforms]);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
|
||||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||||
import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router";
|
import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
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 { systemApi } from "../scripts/clientApi";
|
||||||
import { FOCUS_KEYS } from "../scripts/types";
|
import { FOCUS_KEYS } from "../scripts/types";
|
||||||
|
|
||||||
|
|
@ -54,12 +54,24 @@ export default function SelectMenu (data: { rootFocusKey: string; })
|
||||||
action (ctx)
|
action (ctx)
|
||||||
{
|
{
|
||||||
setOpen(false);
|
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",
|
type: "accent",
|
||||||
id: "settings-m"
|
id: "settings-m"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: "Plugins",
|
||||||
|
icon: <Puzzle />,
|
||||||
|
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",
|
content: "Reload",
|
||||||
icon: <RefreshCcw />,
|
icon: <RefreshCcw />,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default function ActionButton (data: {
|
||||||
square?: boolean,
|
square?: boolean,
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
tooltip?: string,
|
tooltip?: string,
|
||||||
tooltip_type?: 'accent' | 'error';
|
tooltipType?: 'accent' | 'error';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
} & InteractParams)
|
} & InteractParams)
|
||||||
{
|
{
|
||||||
|
|
@ -30,7 +30,7 @@ export default function ActionButton (data: {
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}
|
onClick={e => data.onAction?.({ event: e.nativeEvent, focusKey })}
|
||||||
data-tooltip={data.tooltip}
|
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",
|
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)}>
|
"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}
|
{data.icon}
|
||||||
|
|
|
||||||
|
|
@ -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 { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
|
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
|
||||||
import { getErrorMessage } from "react-error-boundary";
|
import { getErrorMessage } from "react-error-boundary";
|
||||||
import toast from "react-hot-toast";
|
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 MainActions from "./MainActions";
|
||||||
import ActionButton from "./ActionButton";
|
import ActionButton from "./ActionButton";
|
||||||
import { useLocalStorage } from "usehooks-ts";
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
|
|
@ -34,7 +34,8 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
||||||
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
|
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
|
||||||
|
|
||||||
const fixMutation = useMutation({
|
const fixMutation = useMutation({
|
||||||
...fixSourceMutation, onSuccess (data, variables, onMutateResult, context)
|
...fixSourceMutation,
|
||||||
|
onSuccess (data, variables, onMutateResult, context)
|
||||||
{
|
{
|
||||||
if (onMutateResult) toast.success("Updated Source");
|
if (onMutateResult) toast.success("Updated Source");
|
||||||
context.client.invalidateQueries(gameInvalidationQuery(variables.id, variables.source)).then(() => router.history.back());
|
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");
|
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 { data: validation } = useQuery(validateSourceQuery(data.source, data.id));
|
||||||
const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' });
|
const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -62,7 +75,7 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
||||||
useBlocker({
|
useBlocker({
|
||||||
shouldBlockFn: () =>
|
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({
|
contextOptions.push({
|
||||||
id: "fix_source",
|
id: "fix_source",
|
||||||
action (ctx)
|
async action (ctx)
|
||||||
{
|
{
|
||||||
if (data.game)
|
if (!data.game) return;
|
||||||
fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id });
|
await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id });
|
||||||
|
ctx.close();
|
||||||
|
router.navigate({ replace: true });
|
||||||
},
|
},
|
||||||
icon: fixMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Hammer />,
|
icon: fixMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Hammer />,
|
||||||
content: "Try Fix Source",
|
content: "Try Fix Source",
|
||||||
type: "warning"
|
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 ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />,
|
||||||
|
content: "Update Metadata",
|
||||||
|
type: "primary"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList disableCloseButton={deleteMutation.isPending} options={contextOptions} />, canClose: !deleteMutation.isPending });
|
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList disableCloseButton={deleteMutation.isPending} options={contextOptions} />, canClose: !deleteMutation.isPending });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
const platformCoverImg = data.game?.path_platform_cover ? new URL(`${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`) : undefined;
|
||||||
if (platformCoverImg)
|
if (platformCoverImg)
|
||||||
platformCoverImg.searchParams.set("width", "64");
|
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;
|
let fileSizeIcon: JSX.Element | undefined;
|
||||||
if (!data.game)
|
if (!data.game)
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,6 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
||||||
{
|
{
|
||||||
const errorMessage = getErrorMessage(e.data.error);
|
const errorMessage = getErrorMessage(e.data.error);
|
||||||
if (!errorMessage) return;
|
if (!errorMessage) return;
|
||||||
toast.error(errorMessage);
|
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -137,7 +136,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
||||||
mainButton = <ActionButton
|
mainButton = <ActionButton
|
||||||
key="error"
|
key="error"
|
||||||
tooltip={error}
|
tooltip={error}
|
||||||
tooltip-type="error"
|
tooltipType="error"
|
||||||
type='error'
|
type='error'
|
||||||
onAction={() =>
|
onAction={() =>
|
||||||
{
|
{
|
||||||
|
|
@ -169,7 +168,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
||||||
{
|
{
|
||||||
case 'present':
|
case 'present':
|
||||||
case 'install':
|
case 'install':
|
||||||
installMut.mutate();
|
installMut.mutate({});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export function OptionInput (data: {
|
||||||
step?: number;
|
step?: number;
|
||||||
defaultValue?: string | boolean | number;
|
defaultValue?: string | boolean | number;
|
||||||
autocomplete?: HTMLInputAutoCompleteAttribute;
|
autocomplete?: HTMLInputAutoCompleteAttribute;
|
||||||
|
compact?: boolean;
|
||||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
onChange?: (value: string | number | boolean) => void;
|
onChange?: (value: string | number | boolean) => void;
|
||||||
})
|
})
|
||||||
|
|
@ -121,7 +122,7 @@ export function OptionInput (data: {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label ref={ref} className={`flex items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent group-focusable`}>
|
<label ref={ref} className={twMerge(`flex items-center gap-3 rounded-full divide-accent group-focusable`, data.compact !== true ? "sm:flex-2 md:flex-1" : "")}>
|
||||||
{!!data.icon && <span className="text-base-content/80">{data.icon}</span>}
|
{!!data.icon && <span className="text-base-content/80">{data.icon}</span>}
|
||||||
{data.type !== 'checkbox' && <input
|
{data.type !== 'checkbox' && <input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { OptionContext } from "@/mainview/scripts/contexts";
|
import { OptionContext } from "@/mainview/scripts/contexts";
|
||||||
|
import { Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||||
import { Direction, FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
import { Direction, FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { JSX, useContext, useEffect, useMemo, useState } from "react";
|
import { JSX, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
@ -38,6 +39,8 @@ export function OptionSpace (data: {
|
||||||
children?: any | any[];
|
children?: any | any[];
|
||||||
label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
|
label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
|
||||||
saveLastFocusedChild?: boolean;
|
saveLastFocusedChild?: boolean;
|
||||||
|
preferredChildFocusKey?: string;
|
||||||
|
shortcuts?: Shortcut[];
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const [focusBoundary, setFocusBoundary] = useState(false);
|
const [focusBoundary, setFocusBoundary] = useState(false);
|
||||||
|
|
@ -50,6 +53,7 @@ export function OptionSpace (data: {
|
||||||
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
|
saveLastFocusedChild: data.saveLastFocusedChild ?? false,
|
||||||
isFocusBoundary: focusBoundary,
|
isFocusBoundary: focusBoundary,
|
||||||
focusBoundaryDirections,
|
focusBoundaryDirections,
|
||||||
|
preferredChildFocusKey: data.preferredChildFocusKey,
|
||||||
onFocus ()
|
onFocus ()
|
||||||
{
|
{
|
||||||
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
(ref.current as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
|
@ -59,6 +63,7 @@ export function OptionSpace (data: {
|
||||||
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
|
eventTarget.dispatchEvent(new CustomEvent("onEnterPress"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
useShortcuts(focusKey, () => data.shortcuts ?? []);
|
||||||
let labelElement: any = data.label;
|
let labelElement: any = data.label;
|
||||||
if (data.label instanceof Function)
|
if (data.label instanceof Function)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (detail
|
||||||
export function EmulatorsSection (data: {
|
export function EmulatorsSection (data: {
|
||||||
id: string;
|
id: string;
|
||||||
emulators?: FrontEndEmulator[];
|
emulators?: FrontEndEmulator[];
|
||||||
onSelect?: (id: string, focusKey: string) => void;
|
onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
|
||||||
header?: any;
|
header?: any;
|
||||||
} & FocusParams)
|
} & FocusParams)
|
||||||
{
|
{
|
||||||
|
|
@ -64,7 +64,7 @@ export function EmulatorsSection (data: {
|
||||||
|
|
||||||
<Carousel scrollRef={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 pb-4 px-4 gap-4 select-none">
|
<Carousel scrollRef={containerRef} className="flex *:min-w-[18rem] overflow-y-hidden overflow-x-scroll scrollbar-none py-2 pb-4 px-4 gap-4 select-none">
|
||||||
{data.emulators?.map((em) => (
|
{data.emulators?.map((em) => (
|
||||||
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em.name, focusKey)} onFocus={({ node, details }) =>
|
<StoreEmulatorCard id={`${data.id}-${em.name}`} key={em.name} emulator={em} onSelect={(id, focusKey) => data.onSelect?.(em, focusKey)} onFocus={({ node, details }) =>
|
||||||
{
|
{
|
||||||
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
|
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
|
||||||
}} />
|
}} />
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { Route as StoreTabRouteRouteImport } from './../routes/store/tab/route'
|
||||||
import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
|
import { Route as StoreTabIndexRouteImport } from './../routes/store/tab/index'
|
||||||
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games'
|
import { Route as StoreTabGamesRouteImport } from './../routes/store/tab/games'
|
||||||
import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators'
|
import { Route as StoreTabEmulatorsRouteImport } from './../routes/store/tab/emulators'
|
||||||
|
import { Route as SettingsPluginSourceRouteImport } from './../routes/settings/plugin.$source'
|
||||||
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
|
import { Route as PlatformSourceIdRouteImport } from './../routes/platform.$source.$id'
|
||||||
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
|
import { Route as LauncherSourceIdRouteImport } from './../routes/launcher.$source.$id'
|
||||||
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
|
import { Route as GameSourceIdRouteImport } from './../routes/game/$source.$id'
|
||||||
|
|
@ -94,6 +95,11 @@ const StoreTabEmulatorsRoute = StoreTabEmulatorsRouteImport.update({
|
||||||
path: '/emulators',
|
path: '/emulators',
|
||||||
getParentRoute: () => StoreTabRouteRoute,
|
getParentRoute: () => StoreTabRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SettingsPluginSourceRoute = SettingsPluginSourceRouteImport.update({
|
||||||
|
id: '/plugin/$source',
|
||||||
|
path: '/plugin/$source',
|
||||||
|
getParentRoute: () => SettingsRouteRoute,
|
||||||
|
} as any)
|
||||||
const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({
|
const PlatformSourceIdRoute = PlatformSourceIdRouteImport.update({
|
||||||
id: '/platform/$source/$id',
|
id: '/platform/$source/$id',
|
||||||
path: '/platform/$source/$id',
|
path: '/platform/$source/$id',
|
||||||
|
|
@ -141,6 +147,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/game/$source/$id': typeof GameSourceIdRoute
|
'/game/$source/$id': typeof GameSourceIdRoute
|
||||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||||
|
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
|
||||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||||
'/store/tab/games': typeof StoreTabGamesRoute
|
'/store/tab/games': typeof StoreTabGamesRoute
|
||||||
'/store/tab/': typeof StoreTabIndexRoute
|
'/store/tab/': typeof StoreTabIndexRoute
|
||||||
|
|
@ -161,6 +168,7 @@ export interface FileRoutesByTo {
|
||||||
'/game/$source/$id': typeof GameSourceIdRoute
|
'/game/$source/$id': typeof GameSourceIdRoute
|
||||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||||
|
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
|
||||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||||
'/store/tab/games': typeof StoreTabGamesRoute
|
'/store/tab/games': typeof StoreTabGamesRoute
|
||||||
'/store/tab': typeof StoreTabIndexRoute
|
'/store/tab': typeof StoreTabIndexRoute
|
||||||
|
|
@ -183,6 +191,7 @@ export interface FileRoutesById {
|
||||||
'/game/$source/$id': typeof GameSourceIdRoute
|
'/game/$source/$id': typeof GameSourceIdRoute
|
||||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||||
|
'/settings/plugin/$source': typeof SettingsPluginSourceRoute
|
||||||
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
'/store/tab/emulators': typeof StoreTabEmulatorsRoute
|
||||||
'/store/tab/games': typeof StoreTabGamesRoute
|
'/store/tab/games': typeof StoreTabGamesRoute
|
||||||
'/store/tab/': typeof StoreTabIndexRoute
|
'/store/tab/': typeof StoreTabIndexRoute
|
||||||
|
|
@ -206,6 +215,7 @@ export interface FileRouteTypes {
|
||||||
| '/game/$source/$id'
|
| '/game/$source/$id'
|
||||||
| '/launcher/$source/$id'
|
| '/launcher/$source/$id'
|
||||||
| '/platform/$source/$id'
|
| '/platform/$source/$id'
|
||||||
|
| '/settings/plugin/$source'
|
||||||
| '/store/tab/emulators'
|
| '/store/tab/emulators'
|
||||||
| '/store/tab/games'
|
| '/store/tab/games'
|
||||||
| '/store/tab/'
|
| '/store/tab/'
|
||||||
|
|
@ -226,6 +236,7 @@ export interface FileRouteTypes {
|
||||||
| '/game/$source/$id'
|
| '/game/$source/$id'
|
||||||
| '/launcher/$source/$id'
|
| '/launcher/$source/$id'
|
||||||
| '/platform/$source/$id'
|
| '/platform/$source/$id'
|
||||||
|
| '/settings/plugin/$source'
|
||||||
| '/store/tab/emulators'
|
| '/store/tab/emulators'
|
||||||
| '/store/tab/games'
|
| '/store/tab/games'
|
||||||
| '/store/tab'
|
| '/store/tab'
|
||||||
|
|
@ -247,6 +258,7 @@ export interface FileRouteTypes {
|
||||||
| '/game/$source/$id'
|
| '/game/$source/$id'
|
||||||
| '/launcher/$source/$id'
|
| '/launcher/$source/$id'
|
||||||
| '/platform/$source/$id'
|
| '/platform/$source/$id'
|
||||||
|
| '/settings/plugin/$source'
|
||||||
| '/store/tab/emulators'
|
| '/store/tab/emulators'
|
||||||
| '/store/tab/games'
|
| '/store/tab/games'
|
||||||
| '/store/tab/'
|
| '/store/tab/'
|
||||||
|
|
@ -359,6 +371,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof StoreTabEmulatorsRouteImport
|
preLoaderRoute: typeof StoreTabEmulatorsRouteImport
|
||||||
parentRoute: typeof StoreTabRouteRoute
|
parentRoute: typeof StoreTabRouteRoute
|
||||||
}
|
}
|
||||||
|
'/settings/plugin/$source': {
|
||||||
|
id: '/settings/plugin/$source'
|
||||||
|
path: '/plugin/$source'
|
||||||
|
fullPath: '/settings/plugin/$source'
|
||||||
|
preLoaderRoute: typeof SettingsPluginSourceRouteImport
|
||||||
|
parentRoute: typeof SettingsRouteRoute
|
||||||
|
}
|
||||||
'/platform/$source/$id': {
|
'/platform/$source/$id': {
|
||||||
id: '/platform/$source/$id'
|
id: '/platform/$source/$id'
|
||||||
path: '/platform/$source/$id'
|
path: '/platform/$source/$id'
|
||||||
|
|
@ -411,6 +430,7 @@ interface SettingsRouteRouteChildren {
|
||||||
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
|
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
|
||||||
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
|
SettingsInterfaceRoute: typeof SettingsInterfaceRoute
|
||||||
SettingsPluginsRoute: typeof SettingsPluginsRoute
|
SettingsPluginsRoute: typeof SettingsPluginsRoute
|
||||||
|
SettingsPluginSourceRoute: typeof SettingsPluginSourceRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
||||||
|
|
@ -420,6 +440,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
||||||
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
|
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
|
||||||
SettingsInterfaceRoute: SettingsInterfaceRoute,
|
SettingsInterfaceRoute: SettingsInterfaceRoute,
|
||||||
SettingsPluginsRoute: SettingsPluginsRoute,
|
SettingsPluginsRoute: SettingsPluginsRoute,
|
||||||
|
SettingsPluginSourceRoute: SettingsPluginSourceRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
|
|
||||||
const rootElement = document.getElementById("preload")!;
|
const rootElement = document.getElementById("preload")!;
|
||||||
|
|
||||||
|
|
@ -9,13 +10,11 @@ if (!rootElement.innerHTML)
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<div className="in-data-[loaded=true]:hidden absolute flex items-center gap-2 justify-center bg-base-300 w-screen h-screen z-100 font-semibold text-2xl text-shadow-lg">
|
<div className="in-data-[loaded=true]:hidden absolute w-screen h-screen">
|
||||||
<span className="loading loading-spinner loading-xl"></span>
|
<LoadingScreen >
|
||||||
<div className="absolute w-screen h-screen bg-radial from-base-100 to-base-300 -z-2"></div>
|
<span className="loading loading-spinner loading-xl"></span> Loading Gameflow
|
||||||
<div className="bg-noise"></div>
|
</LoadingScreen>
|
||||||
<div className="bg-dots"></div>
|
|
||||||
Loading Gameflow
|
|
||||||
</div>
|
</div>
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ function Frame (data: { ref: RefObject<HTMLIFrameElement | null>; })
|
||||||
|
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
search['gameName'] = game.name;
|
search['gameName'] = game.name;
|
||||||
search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_cover}`;
|
search['backgroundImage'] = `${RPC_URL(__HOST__)}${game.path_covers[0]}`;
|
||||||
search['backgroundBlur'] = "true";
|
search['backgroundBlur'] = "true";
|
||||||
|
|
||||||
if (!__PUBLIC__)
|
if (!__PUBLIC__)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { GamesSection } from "@/mainview/components/store/GamesSection";
|
||||||
import Details from "@/mainview/components/game/Details";
|
import Details from "@/mainview/components/game/Details";
|
||||||
import { AutoFocus } from "@/mainview/components/AutoFocus";
|
import { AutoFocus } from "@/mainview/components/AutoFocus";
|
||||||
import SelectMenu from "@/mainview/components/SelectMenu";
|
import SelectMenu from "@/mainview/components/SelectMenu";
|
||||||
|
import { en } from "zod/v4/locales";
|
||||||
|
|
||||||
export const Route = createFileRoute("/game/$source/$id")({
|
export const Route = createFileRoute("/game/$source/$id")({
|
||||||
loader: async ({ params, context }) =>
|
loader: async ({ params, context }) =>
|
||||||
|
|
@ -145,7 +146,7 @@ function RouteComponent ()
|
||||||
const [, setUpdate] = useState(0);
|
const [, setUpdate] = useState(0);
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true });
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true });
|
||||||
const headerRef = useRef(null);
|
const headerRef = useRef(null);
|
||||||
const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined;
|
const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_covers[0]}`) : undefined;
|
||||||
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible });
|
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible });
|
||||||
|
|
||||||
useShortcuts(focusKey, () => [{
|
useShortcuts(focusKey, () => [{
|
||||||
|
|
@ -185,9 +186,10 @@ function RouteComponent ()
|
||||||
Related Emulators
|
Related Emulators
|
||||||
</h2></>}
|
</h2></>}
|
||||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||||
onSelect={(id, focus) =>
|
onSelect={(em, focus) =>
|
||||||
{
|
{
|
||||||
router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
if (em.source === 'local') return;
|
||||||
|
router.navigate({ to: '/store/details/emulator/$id', params: { id: em.name } });
|
||||||
}}
|
}}
|
||||||
emulators={recommendedEmulators} />}
|
emulators={recommendedEmulators} />}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ function HomeList (data: {
|
||||||
saveChildFocus="session"
|
saveChildFocus="session"
|
||||||
onFocus={(l, n, d) =>
|
onFocus={(l, n, d) =>
|
||||||
{
|
{
|
||||||
const [source, id] = l.split('@');
|
const [source, id] = l.split('@', 1);
|
||||||
queryClient.prefetchQuery(gameQuery(source, id));
|
queryClient.prefetchQuery(gameQuery(source, id));
|
||||||
handleNodeFocus(l, n, d);
|
handleNodeFocus(l, n, d);
|
||||||
}}
|
}}
|
||||||
|
|
@ -238,17 +238,11 @@ function MainMenu ()
|
||||||
label="Home"
|
label="Home"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
/>
|
/>
|
||||||
<CircleIcon icon={<MessageSquare />} label="News" />
|
|
||||||
<CircleIcon type="info" icon={<Store />} onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" />
|
<CircleIcon type="info" icon={<Store />} onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" />
|
||||||
<CircleIcon icon={<Image />} label="Album" />
|
|
||||||
<CircleIcon
|
|
||||||
icon={<Gamepad2 />}
|
|
||||||
label="Controllers"
|
|
||||||
/>
|
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
onAction={(e) =>
|
onAction={(e) =>
|
||||||
{
|
{
|
||||||
router.navigate({ to: '/settings/accounts', state: { eventType: e?.event?.type } });
|
router.navigate({ to: '/settings/interface', state: { eventType: e?.event?.type } });
|
||||||
}}
|
}}
|
||||||
icon={<Settings />}
|
icon={<Settings />}
|
||||||
label="Settings"
|
label="Settings"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
import { CollectionsDetail } from "../components/CollectionsDetail";
|
import { CollectionsDetail } from "../components/CollectionsDetail";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { GameListFilterSchema, GameListFilterType, RPC_URL } from "../../shared/constants";
|
import { GameListFilterSchema, GameListFilterType, RPC_URL } from "../../shared/constants";
|
||||||
import { platformQuery } from "@queries/romm";
|
import { deletePlatformMutation, localPlatformFilter, platformQuery, updatePlatformMutation } from "@queries/romm";
|
||||||
import { zodValidator } from "@tanstack/zod-adapter";
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { useLocalStorage } from "usehooks-ts";
|
import { useLocalStorage } from "usehooks-ts";
|
||||||
|
import { RefreshCcw, Settings2 } from "lucide-react";
|
||||||
|
import { ContextList, DialogEntry, useContextDialog } from "../components/ContextDialog";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
export const Route = createFileRoute("/platform/$source/$id")({
|
export const Route = createFileRoute("/platform/$source/$id")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -31,18 +34,77 @@ function PlatformTitle (data: {})
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { source, id } = Route.useParams();
|
const { source, id } = Route.useParams();
|
||||||
|
const router = useRouter();
|
||||||
const { countHint } = Route.useSearch();
|
const { countHint } = Route.useSearch();
|
||||||
const [filter, setFilter] = useLocalStorage<GameListFilterType>("platforms-filters", {});
|
const [filter, setFilter] = useLocalStorage<GameListFilterType>("platforms-filters", {});
|
||||||
|
const updatePlatform = useMutation({
|
||||||
|
...updatePlatformMutation(id), onSuccess (data, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
context.client.invalidateQueries(localPlatformFilter(id));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletePlatform = useMutation({
|
||||||
|
...deletePlatformMutation(id),
|
||||||
|
onError (error, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
onSuccess (data, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
context.client.invalidateQueries(localPlatformFilter(id));
|
||||||
|
router.history.back();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const settingsOptions: DialogEntry[] = [];
|
||||||
|
if (source === 'local')
|
||||||
|
{
|
||||||
|
settingsOptions.push({
|
||||||
|
id: 'update-platform',
|
||||||
|
type: "primary",
|
||||||
|
content: "Update Platform",
|
||||||
|
icon: updatePlatform.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />,
|
||||||
|
async action (ctx)
|
||||||
|
{
|
||||||
|
await updatePlatform.mutateAsync();
|
||||||
|
ctx.close();
|
||||||
|
router.navigate({ replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsOptions.push({
|
||||||
|
id: 'update-platform',
|
||||||
|
type: "error",
|
||||||
|
content: "Delete",
|
||||||
|
icon: deletePlatform.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcw />,
|
||||||
|
action (ctx)
|
||||||
|
{
|
||||||
|
deletePlatform.mutateAsync();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dialog: platformSettingsDialog, setOpen: setPlatformSettingsOpen } = useContextDialog('platform-settings-dialog', {
|
||||||
|
content: <ContextList options={settingsOptions} />
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<CollectionsDetail
|
<CollectionsDetail
|
||||||
localFilter={filter}
|
localFilter={filter}
|
||||||
setLocalFilter={setFilter}
|
setLocalFilter={setFilter}
|
||||||
|
headerButtons={[{
|
||||||
|
id: 'open-platform-settings-btn',
|
||||||
|
icon: <Settings2 />,
|
||||||
|
action ()
|
||||||
|
{
|
||||||
|
setPlatformSettingsOpen(true);
|
||||||
|
},
|
||||||
|
}]}
|
||||||
countHint={countHint}
|
countHint={countHint}
|
||||||
title={<PlatformTitle />}
|
title={<PlatformTitle />}
|
||||||
filters={{ platform_id: Number(id), platform_source: source }}
|
filters={{ platform_id: Number(id), platform_source: source }}
|
||||||
/>
|
/>
|
||||||
|
{platformSettingsDialog}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import
|
||||||
useFocusable,
|
useFocusable,
|
||||||
} from "@noriginmedia/norigin-spatial-navigation";
|
} from "@noriginmedia/norigin-spatial-navigation";
|
||||||
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
|
import { useIsMutating, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { Key, Link, Lock, LogIn, LogOut, ScanQrCode, User, X } from "lucide-react";
|
import { Key, Link, Lock, LogIn, LogOut, ScanQrCode, User, X } from "lucide-react";
|
||||||
import
|
import
|
||||||
|
|
@ -90,6 +90,7 @@ function TwitchLogin ()
|
||||||
function LoginControls (data: {})
|
function LoginControls (data: {})
|
||||||
{
|
{
|
||||||
const user = useQuery(rommUserQuery);
|
const user = useQuery(rommUserQuery);
|
||||||
|
const router = useRouter();
|
||||||
const loginMutation = useMutation(rommQrLoginMutation);
|
const loginMutation = useMutation(rommQrLoginMutation);
|
||||||
const { data: statusValue, wsRef } = useJobStatus('login-job');
|
const { data: statusValue, wsRef } = useJobStatus('login-job');
|
||||||
const { data: loginStatusData } = useQuery(rommLoggedInQuery);
|
const { data: loginStatusData } = useQuery(rommLoggedInQuery);
|
||||||
|
|
@ -100,7 +101,8 @@ function LoginControls (data: {})
|
||||||
onSuccess: async (d, v, r, c) =>
|
onSuccess: async (d, v, r, c) =>
|
||||||
{
|
{
|
||||||
user.refetch();
|
user.refetch();
|
||||||
c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
await c.client.invalidateQueries({ queryKey: ["romm", "auth"] });
|
||||||
|
await router.navigate({ replace: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return <div className="flex gap-2 items-center flex-wrap justify-center-safe">
|
return <div className="flex gap-2 items-center flex-wrap justify-center-safe">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { OptionInput } from '../../components/options/OptionInput';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button } from '../../components/options/Button';
|
import { Button } from '../../components/options/Button';
|
||||||
import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Store, Trash } from 'lucide-react';
|
import { Check, ChevronDown, FileQuestion, FolderSearch, HardDrive, Plug, SearchAlert, Store, Trash } from 'lucide-react';
|
||||||
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
@ -248,7 +248,7 @@ function EmulatorBadge (data: {
|
||||||
{data.emulator.validSources.length > 0 && <div className="divider">
|
{data.emulator.validSources.length > 0 && <div className="divider">
|
||||||
<div className='flex p-2 gap-1'>{data.emulator.validSources.map(s =>
|
<div className='flex p-2 gap-1'>{data.emulator.validSources.map(s =>
|
||||||
{
|
{
|
||||||
let icon = <FileQuestion />;
|
let icon = <HardDrive />;
|
||||||
let action: (() => void) | undefined = undefined;
|
let action: (() => void) | undefined = undefined;
|
||||||
let className = "bg-warning text-warning-content";
|
let className = "bg-warning text-warning-content";
|
||||||
switch (s.type)
|
switch (s.type)
|
||||||
|
|
|
||||||
158
src/mainview/routes/settings/plugin.$source.tsx
Normal file
158
src/mainview/routes/settings/plugin.$source.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { AutoFocus } from '@/mainview/components/AutoFocus';
|
||||||
|
import { Button } from '@/mainview/components/options/Button';
|
||||||
|
import { OptionDropdown } from '@/mainview/components/options/OptionDropdown';
|
||||||
|
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
||||||
|
import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
||||||
|
import { RoundButton } from '@/mainview/components/RoundButton';
|
||||||
|
import { getAllPluginsQuery, getPluginDetailsQuery } from '@/mainview/scripts/queries/plugins';
|
||||||
|
import { getPluginActionsQuery, getPluginSettingQuery, getPluginSettingsDefinitionQuery, pluginActionMutation, setPluginSettingMutation } from '@/mainview/scripts/queries/settings';
|
||||||
|
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||||
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { JSONSchema7 } from 'json-schema';
|
||||||
|
import { ArrowLeft, CirclePlay, Play, Settings2, SettingsIcon } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
export const Route = createFileRoute('/settings/plugin/$source')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function PluginAction (data: { id: string, title: string | undefined, description: string | undefined; action: string; reload: () => void; })
|
||||||
|
{
|
||||||
|
const { source } = Route.useParams();
|
||||||
|
const action = useMutation({
|
||||||
|
...pluginActionMutation(source, data.id),
|
||||||
|
onSuccess (acitonData, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
if (acitonData.data?.openTab)
|
||||||
|
{
|
||||||
|
window.open(acitonData.data?.openTab, "_blank");
|
||||||
|
} else if (acitonData.data?.reload)
|
||||||
|
{
|
||||||
|
data.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return <OptionSpace
|
||||||
|
id={`${data.id}-option`}
|
||||||
|
label={
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<div>{data.title ?? data.id}</div>
|
||||||
|
<div className='text-sm text-base-content/40 text-wrap'>{data.description}</div>
|
||||||
|
</div>}>
|
||||||
|
<Button id={`${data.id}-btn`} onAction={e => action.mutate()} >{data.action}</Button>
|
||||||
|
</OptionSpace>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginOption (data: { name: string, title?: string, prop: JSONSchema7; })
|
||||||
|
{
|
||||||
|
const { source } = Route.useParams();
|
||||||
|
const { data: value, refetch: refetchValue } = useQuery(getPluginSettingQuery(source, data.name));
|
||||||
|
const setValue = useMutation({
|
||||||
|
...setPluginSettingMutation(source, data.name),
|
||||||
|
onError (error, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
onSuccess (data, variables, onMutateResult, context)
|
||||||
|
{
|
||||||
|
refetchValue();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let input: any = undefined;
|
||||||
|
switch (data.prop.type)
|
||||||
|
{
|
||||||
|
case "string":
|
||||||
|
if (Array.isArray(data.prop.examples))
|
||||||
|
{
|
||||||
|
input = <OptionDropdown name={data.name} values={data.prop.examples.filter(e => !!e).map(e => e!.toString())} onChange={v => setValue.mutate(v)} value={value?.value as any} />;
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
input = <OptionInput value={value?.value as any} onChange={v => setValue.mutate(v)} type="text" name={data.name} />;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
input = <OptionInput value={value?.value as any} onChange={v => setValue.mutate(v)} type='checkbox' name={data.name} />;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return <OptionSpace
|
||||||
|
id={`${data.name}-option`}
|
||||||
|
label={
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<div>{data.title ?? data.name}</div>
|
||||||
|
<div className='text-sm text-base-content/40 text-wrap'>{data.prop.description}</div>
|
||||||
|
</div>}>
|
||||||
|
{input}
|
||||||
|
</OptionSpace>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Settings ()
|
||||||
|
{
|
||||||
|
const { source } = Route.useParams();
|
||||||
|
const { data: definitions, refetch: refetchDefinitions } = useQuery(getPluginSettingsDefinitionQuery(source));
|
||||||
|
const { data: actions, refetch: referchActions } = useQuery(getPluginActionsQuery(source));
|
||||||
|
const handleReload = () =>
|
||||||
|
{
|
||||||
|
referchActions();
|
||||||
|
refetchDefinitions();
|
||||||
|
};
|
||||||
|
const { ref, focusKey } = useFocusable({ focusKey: 'plugin-settings' });
|
||||||
|
return <div ref={ref}>
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
{!!definitions?.properties && Object.entries(Object.groupBy(Object.entries(definitions?.properties)
|
||||||
|
.filter(([key, prop]) => typeof prop === 'object'), ([key, prop]) =>
|
||||||
|
{
|
||||||
|
const schema = prop as JSONSchema7;
|
||||||
|
if (schema.$comment)
|
||||||
|
{
|
||||||
|
const meta = JSON.parse(schema.$comment);
|
||||||
|
return meta.category;
|
||||||
|
}
|
||||||
|
return "settings";
|
||||||
|
})).map(([cat, data]) =>
|
||||||
|
{
|
||||||
|
return <div className='flex flex-col gap-1'>
|
||||||
|
<div className="divider">{cat !== "settings" ? cat : <><Settings2 className='size-14' /> Settings</>}</div>
|
||||||
|
{data?.map(([key, prop]) =>
|
||||||
|
{
|
||||||
|
const schema = prop as JSONSchema7;
|
||||||
|
return <PluginOption key={key} title={schema.title} name={key} prop={schema} />;
|
||||||
|
})}
|
||||||
|
</div>;
|
||||||
|
|
||||||
|
})}
|
||||||
|
<div className="divider"><CirclePlay className='size-14' /> Actions</div>
|
||||||
|
{actions?.map(a => <PluginAction key={a.id} id={a.id} title={a.title} description={a.description} action={a.action} reload={handleReload} />)}
|
||||||
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent ()
|
||||||
|
{
|
||||||
|
const { source } = Route.useParams();
|
||||||
|
|
||||||
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'plugins' });
|
||||||
|
const { data } = useQuery(getPluginDetailsQuery(source));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleReturn = () => navigate({ to: '/settings/plugins', replace: true, viewTransition: { types: ['slide-up'] } });
|
||||||
|
useShortcuts(focusKey, () => [{ label: "Return", button: GamePadButtonCode.B, action: handleReturn }]);
|
||||||
|
|
||||||
|
return <div ref={ref}>
|
||||||
|
<FocusContext value={focusKey}>
|
||||||
|
<RoundButton className='absolute' id='return-to-plugins' onAction={handleReturn}><ArrowLeft /></RoundButton>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<div className='flex text-2xl font-bold gap-2 grow items-center justify-center'>
|
||||||
|
<img className='h-12' src={data?.icon}></img>
|
||||||
|
{data?.displayName}
|
||||||
|
</div>
|
||||||
|
<ul className='flex gap-2 justify-center'>{data?.keywords?.map((k, i) => <li key={i} className='bg-base-200 rounded-full p-2 px-4'>{k}</li>)}</ul>
|
||||||
|
<div className='bg-base-200 p-4 rounded-2xl'>{data?.description}</div>
|
||||||
|
</div>
|
||||||
|
<Settings />
|
||||||
|
</FocusContext>
|
||||||
|
<AutoFocus focus={focusSelf} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
|
import { pluginCategoryIcons, pluginCategoryPriorities } from '@/mainview/components/Constants';
|
||||||
import { Button } from '@/mainview/components/options/Button';
|
import { Button } from '@/mainview/components/options/Button';
|
||||||
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
import { OptionInput } from '@/mainview/components/options/OptionInput';
|
||||||
import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
import { OptionSpace } from '@/mainview/components/options/OptionSpace';
|
||||||
|
import { RoundButton } from '@/mainview/components/RoundButton';
|
||||||
import { enablePluginMutation, getAllPluginsQuery } from '@/mainview/scripts/queries/plugins';
|
import { enablePluginMutation, getAllPluginsQuery } from '@/mainview/scripts/queries/plugins';
|
||||||
|
import { GamePadButtonCode, Shortcut } from '@/mainview/scripts/shortcuts';
|
||||||
|
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
import { Puzzle, Search } from 'lucide-react';
|
import { Eye, Puzzle, Search, Settings2 } from 'lucide-react';
|
||||||
|
|
||||||
export const Route = createFileRoute('/settings/plugins')({
|
export const Route = createFileRoute('/settings/plugins')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -19,23 +23,46 @@ function Plugin (data: {
|
||||||
setEnabled: (enabled: boolean) => void;
|
setEnabled: (enabled: boolean) => void;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
return <OptionSpace label={<div className='flex gap-4 items-center'>
|
const shortcuts: Shortcut[] = [];
|
||||||
|
const navigate = useNavigate();
|
||||||
|
if (data.plugin.hasSettings)
|
||||||
|
shortcuts.push({
|
||||||
|
button: GamePadButtonCode.Y, label: "Details", action (e)
|
||||||
|
{
|
||||||
|
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handleDetails = () => navigate({ to: '/settings/plugin/$source', params: { source: data.plugin.name }, replace: true, viewTransition: { types: ['slide-up'] } });
|
||||||
|
|
||||||
|
return <OptionSpace
|
||||||
|
label={
|
||||||
|
<div className='flex gap-4 items-center'>
|
||||||
<div className='flex bg-accent text-accent-content rounded-full size-12 p-2 items-center justify-center'>
|
<div className='flex bg-accent text-accent-content rounded-full size-12 p-2 items-center justify-center'>
|
||||||
{data.plugin.icon ? <img src={data.plugin.icon}></img> : <Puzzle />}
|
{data.plugin.icon ? <img src={data.plugin.icon}></img> : <Puzzle />}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
<div>{data.plugin.displayName}</div>
|
<div>{data.plugin.displayName}</div>
|
||||||
<div className='text-sm text-base-content/40'>{data.plugin.name} ({data.plugin.version})</div>
|
<div className='flex gap-2 items-center'>
|
||||||
|
<div className=' text-sm text-base-content/40'>{data.plugin.name} ({data.plugin.version})</div>
|
||||||
|
{data.plugin.hasSettings && <Settings2 className='bg-base-300 rounded-full p-1 size-6' />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className='flex p-4 bg-base-200 rounded-3xl scroll-m-12'
|
||||||
|
shortcuts={shortcuts}
|
||||||
|
>
|
||||||
|
<div className='flex gap-4'>
|
||||||
|
<RoundButton className='size-12 p-1' onAction={handleDetails} id={`${data.plugin.name}-details`} >{data.plugin.hasSettings ? <Settings2 /> : <Eye />}</RoundButton>
|
||||||
|
{data.plugin.canDisable && <OptionInput compact onChange={v => data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />}
|
||||||
</div>
|
</div>
|
||||||
</div>} className='flex p-4 bg-base-200 rounded-3xl'>
|
|
||||||
<OptionInput onChange={v => data.setEnabled(!!v)} value={data.plugin.enabled} name={data.plugin.name} type="checkbox" />
|
|
||||||
<Button id={`${data.plugin.name}-details`} ><Search /> Details</Button>
|
|
||||||
</OptionSpace>;
|
</OptionSpace>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteComponent ()
|
function RouteComponent ()
|
||||||
{
|
{
|
||||||
const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery);
|
const { data: plugins, refetch: refetchPlugins } = useQuery(getAllPluginsQuery);
|
||||||
|
const { ref, focusKey } = useFocusable({ focusKey: 'plugins' });
|
||||||
const pluginMutation = useMutation({
|
const pluginMutation = useMutation({
|
||||||
...enablePluginMutation, onSuccess (data, variables, onMutateResult, context)
|
...enablePluginMutation, onSuccess (data, variables, onMutateResult, context)
|
||||||
{
|
{
|
||||||
|
|
@ -43,15 +70,20 @@ function RouteComponent ()
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return <>
|
return <div ref={ref}>
|
||||||
{!!plugins && Object.entries(Object.groupBy(plugins, p => p.source)).map(([source, plugins]) =>
|
<FocusContext value={focusKey}>
|
||||||
|
{!!plugins && Object.entries(Object.groupBy(plugins, p => p.category))
|
||||||
|
.filter(([cat, plugins]) => !!plugins)
|
||||||
|
.toSorted(([catA], [catB]) => pluginCategoryPriorities[catB] - pluginCategoryPriorities[catA])
|
||||||
|
.map(([cat, plugins]) =>
|
||||||
{
|
{
|
||||||
return <>
|
return <div key={cat}>
|
||||||
<div className="divider">{source === 'builtin' ? "Built In" : "Store"}</div>
|
<div className="divider *:size-14">{pluginCategoryIcons[cat]}{cat}</div>
|
||||||
<div className='flex flex-col gap-2'>
|
<div className='flex flex-col gap-2'>
|
||||||
{plugins.map(p => <Plugin key={p.name} plugin={p} setEnabled={(v) => pluginMutation.mutate({ id: p.name, enabled: v })} />)}
|
{plugins!.map(p => <Plugin key={p.name} plugin={p} setEnabled={(v) => pluginMutation.mutate({ id: p.name, enabled: v })} />)}
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</div>;
|
||||||
})}
|
})}
|
||||||
</>;
|
</FocusContext>
|
||||||
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ import
|
||||||
Outlet,
|
Outlet,
|
||||||
createFileRoute,
|
createFileRoute,
|
||||||
useMatch,
|
useMatch,
|
||||||
|
useMatchRoute,
|
||||||
useRouter,
|
useRouter,
|
||||||
|
useRouterState,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { ViewTransitionOptions } from "@tanstack/router-core";
|
import { ViewTransitionOptions } from "@tanstack/router-core";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
@ -22,7 +24,7 @@ import
|
||||||
MonitorCog,
|
MonitorCog,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { JSX } from "react";
|
import { JSX, useMemo } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { SettingsSchema } from "../../../shared/constants";
|
import { SettingsSchema } from "../../../shared/constants";
|
||||||
|
|
@ -45,17 +47,23 @@ export const Route = createFileRoute("/settings")({
|
||||||
|
|
||||||
function MenuItem (data: {
|
function MenuItem (data: {
|
||||||
route: string;
|
route: string;
|
||||||
|
matchRoutes?: string[];
|
||||||
return?: boolean;
|
return?: boolean;
|
||||||
viewTransition?: boolean | ViewTransitionOptions;
|
viewTransition?: boolean | ViewTransitionOptions;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
focusSelect?: boolean;
|
focusSelect?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
linkClassName?: string;
|
linkClassName?: string;
|
||||||
|
active?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const acitve = !!useMatch({ from: data.route as any, shouldThrow: false });;
|
const routerState = useRouterState();
|
||||||
|
const matchRoute = useMatchRoute();
|
||||||
|
|
||||||
|
const acitve = useMemo(() => data.matchRoutes ? data.matchRoutes.some(r => !!matchRoute({ to: r })) : !!router.matchRoute({ to: data.route }),
|
||||||
|
[routerState, matchRoute, data.matchRoutes, data.route]);
|
||||||
const handleNonFocusSelect = (e?: Event) =>
|
const handleNonFocusSelect = (e?: Event) =>
|
||||||
{
|
{
|
||||||
if (data.return)
|
if (data.return)
|
||||||
|
|
@ -114,10 +122,11 @@ function MenuItem (data: {
|
||||||
|
|
||||||
function SettingsMenu (data: {})
|
function SettingsMenu (data: {})
|
||||||
{
|
{
|
||||||
|
const router = useRouter();
|
||||||
const { ref, focusKey } = useFocusable({
|
const { ref, focusKey } = useFocusable({
|
||||||
focusable: true,
|
focusable: true,
|
||||||
focusKey: 'settings-menu',
|
focusKey: 'settings-menu',
|
||||||
preferredChildFocusKey: `menu-item-${location.hash.replaceAll(/#|(\?.+)/g, '')}`
|
preferredChildFocusKey: `menu-item-${router.history.location.pathname}`
|
||||||
});
|
});
|
||||||
|
|
||||||
return <ul
|
return <ul
|
||||||
|
|
@ -146,16 +155,17 @@ function SettingsMenu (data: {})
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
focusSelect
|
focusSelect
|
||||||
route="/settings/directories"
|
matchRoutes={["/settings/plugin/$source", "/settings/plugins"]}
|
||||||
label="Directories"
|
|
||||||
icon={<HardDrive />}
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
focusSelect
|
|
||||||
route="/settings/plugins"
|
route="/settings/plugins"
|
||||||
label="Plugins"
|
label="Plugins"
|
||||||
icon={<Puzzle />}
|
icon={<Puzzle />}
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
focusSelect
|
||||||
|
route="/settings/directories"
|
||||||
|
label="Directories"
|
||||||
|
icon={<HardDrive />}
|
||||||
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
focusSelect
|
focusSelect
|
||||||
route="/settings/about"
|
route="/settings/about"
|
||||||
|
|
|
||||||
|
|
@ -387,10 +387,11 @@ export function RouteComponent ()
|
||||||
More Emulators
|
More Emulators
|
||||||
</h2></>}
|
</h2></>}
|
||||||
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
onFocus={scrollIntoViewHandler({ block: 'center' })}
|
||||||
onSelect={(id, focus) =>
|
onSelect={(em, focus) =>
|
||||||
{
|
{
|
||||||
|
if (em.source === 'local') return;
|
||||||
router.navigate({
|
router.navigate({
|
||||||
to: '/store/details/emulator/$id', params: { id }
|
to: '/store/details/emulator/$id', params: { id: em.name }
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
emulators={recommendedEmulators} />
|
emulators={recommendedEmulators} />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/no
|
||||||
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router';
|
import { createFileRoute, useNavigate, useSearch } from '@tanstack/react-router';
|
||||||
import { Gamepad2, HardDrive } from 'lucide-react';
|
import { Gamepad2, HardDrive } from 'lucide-react';
|
||||||
import { JSX, useContext, useEffect, useState } from 'react';
|
import { JSX, useContext, useEffect, useState } from 'react';
|
||||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
import FrontEndGameCard from '@/mainview/components/FrontEndGameCard';
|
||||||
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
|
||||||
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
|
||||||
|
|
@ -15,6 +15,7 @@ import { useSessionStorage } from 'usehooks-ts';
|
||||||
import { zodValidator } from '@tanstack/zod-adapter';
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import SideFilters from '@/mainview/components/SideFilters';
|
import SideFilters from '@/mainview/components/SideFilters';
|
||||||
|
import { gameFiltersQuery } from '@/mainview/scripts/queries/romm';
|
||||||
|
|
||||||
export const Route = createFileRoute('/store/tab/games')({
|
export const Route = createFileRoute('/store/tab/games')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -32,7 +33,7 @@ function RouteComponent ()
|
||||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "main-area", preferredChildFocusKey: focus });
|
||||||
const [filter, setFilter] = useSessionStorage<GameListFilterType>('store-games-filters', {});
|
const [filter, setFilter] = useSessionStorage<GameListFilterType>('store-games-filters', {});
|
||||||
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter));
|
const { data, fetchNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery(storeGamesInfiniteQuery(filter));
|
||||||
const [filterValues, setFilterValues] = useState<FrontEndFilterLists>();
|
const { data: gameFilters } = useQuery(gameFiltersQuery({ source: 'store' }));
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -86,23 +87,32 @@ function RouteComponent ()
|
||||||
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
badges.push(<HardDrive className="sm:size-4 md:size-8 md:p-1 m-1" />);
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewUrl = new URL(`${RPC_URL(__HOST__)}${g.path_cover}`);
|
const previewUrls = g.path_covers.map(c =>
|
||||||
previewUrl.searchParams.delete('ts');
|
{
|
||||||
|
const url = new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||||
|
url.searchParams.delete('ts');
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
let subtitle: string | JSX.Element | undefined = undefined;
|
||||||
|
if (g.path_platform_cover)
|
||||||
|
{
|
||||||
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
const platformUrl = new URL(`${RPC_URL(__HOST__)}${g.path_platform_cover}`);
|
||||||
platformUrl.searchParams.set('width', "64");
|
platformUrl.searchParams.set('width', "64");
|
||||||
|
subtitle = <div className="flex gap-1 items-center">
|
||||||
|
{!!g.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
||||||
|
<p className="opacity-80">{g.platform_display_name}</p>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${g.id.source}@${g.id.id}`,
|
id: `${g.id.source}@${g.id.id}`,
|
||||||
focusKey: `${g.id.source}@${g.id.id}`,
|
focusKey: `${g.id.source}@${g.id.id}`,
|
||||||
title: g.name ?? "",
|
title: g.name ?? "",
|
||||||
subtitle: (
|
subtitle,
|
||||||
<div className="flex gap-1 items-center">
|
previewUrls,
|
||||||
{!!g.path_platform_cover && <img className="sm:hidden md:inline size-4" src={platformUrl.href} />}
|
|
||||||
<p className="opacity-80">{g.platform_display_name}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
previewUrl: previewUrl.href,
|
|
||||||
badges: badges,
|
badges: badges,
|
||||||
onSelect: () => handleDefaultSelect(g),
|
onSelect: () => handleDefaultSelect(g),
|
||||||
onFocus: (k, n, d) => handleFocus(k, n, d)
|
onFocus: (k, n, d) => handleFocus(k, n, d)
|
||||||
|
|
@ -111,7 +121,7 @@ function RouteComponent ()
|
||||||
) ?? []} id={'store-games'} />
|
) ?? []} id={'store-games'} />
|
||||||
</div>
|
</div>
|
||||||
<div className='fixed left-2 top-52 bottom-0 sm:w-10 md:w-14 z-10'>
|
<div className='fixed left-2 top-52 bottom-0 sm:w-10 md:w-14 z-10'>
|
||||||
<SideFilters id='filter-btns' localFilter={filter} setLocalFilter={setFilter} filterValues={filterValues} filters={{ source: 'store' }} />
|
<SideFilters id='filter-btns' localFilter={filter} setLocalFilter={setFilter} filterValues={gameFilters} filters={{ source: 'store' }} />
|
||||||
</div>
|
</div>
|
||||||
</FocusContext>
|
</FocusContext>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,13 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
||||||
}, 10);
|
}, 10);
|
||||||
|
|
||||||
const storeContext = useContext(StoreContext);
|
const storeContext = useContext(StoreContext);
|
||||||
const previewUrl = data.games ? new URL(`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`) : undefined;
|
const previewUrls = data.games?.[selectedGame] ? data.games[selectedGame].path_covers.map(c =>
|
||||||
previewUrl?.searchParams.set('blur', '16');
|
{
|
||||||
|
const url = new URL(`${RPC_URL(__HOST__)}${c}`);
|
||||||
|
url.searchParams.set('blur', '16');
|
||||||
|
return url;
|
||||||
|
}) : undefined;
|
||||||
|
|
||||||
|
|
||||||
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable md:px-12 p-4 mt-4 gap-6'>
|
return <div ref={ref} className='flex sm:flex-wrap md:flex-nowrap group-focusable md:px-12 p-4 mt-4 gap-6'>
|
||||||
|
|
||||||
|
|
@ -59,22 +64,23 @@ function Main (data: { games?: FrontEndGameTypeDetailed[]; })
|
||||||
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-md w-full ring-6 ring-base-200 border-6 border-base-200">
|
{game ? <div key={selectedGame} className="flex transition-all duration-500 flex-col rounded-3xl overflow-hidden shadow-black/5 shadow-md w-full ring-6 ring-base-200 border-6 border-base-200">
|
||||||
<div className='flex relative h-full overflow-hidden'>
|
<div className='flex relative h-full overflow-hidden'>
|
||||||
<div className='absolute w-full h-full z-0 bg-base-200'>
|
<div className='absolute w-full h-full z-0 bg-base-200'>
|
||||||
<img key={selectedGame}
|
<picture key={selectedGame}
|
||||||
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-80 z-0'
|
className='w-full h-full object-cover transition-all duration-500 ease-out scale-110 opacity-0 light:data-loaded:opacity-40 dark:data-loaded:opacity-80 z-0'
|
||||||
src={previewUrl?.href}
|
|
||||||
onLoad={(e) =>
|
onLoad={(e) =>
|
||||||
{
|
{
|
||||||
e.currentTarget.dataset.loaded = "true";
|
e.currentTarget.dataset.loaded = "true";
|
||||||
e.currentTarget.classList.toggle('scale-110', false);
|
e.currentTarget.classList.toggle('scale-110', false);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{previewUrls?.map((u, i) => <source key={i} src={u.href} />)}
|
||||||
|
</picture>
|
||||||
</div>
|
</div>
|
||||||
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
|
<div key={selectedGame} className='flex sm:flex-wrap md:flex-nowrap grow z-1 p-8 opacity-0 animate-fade-in h-full items-end gap-4 sm:justify-end md:justify-between'>
|
||||||
<div className='flex gap-4 max-h-full z-1 grow md:h-full'>
|
<div className='flex gap-4 max-h-full z-1 grow md:h-full'>
|
||||||
<div className='flex sm:portrait:flex-wrap sm:portrait:grow gap-4 max-h-full justify-center'>
|
<div className='flex sm:portrait:flex-wrap sm:portrait:grow gap-4 max-h-full justify-center'>
|
||||||
<div className='relative rounded-3xl max-w-xs h-48 overflow-hidden shadow-lg'>
|
<div className='relative rounded-3xl max-w-xs h-48 overflow-hidden shadow-lg'>
|
||||||
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg '><HardDrive /></div>
|
<div className='flex absolute bottom-4 left-4 size-8 bg-base-content text-base-100 rounded-full items-center justify-center shadow-lg '><HardDrive /></div>
|
||||||
{!!data.games && <img className='object-cover w-full h-full ' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_cover}`} />}
|
{!!data.games && <img className='object-cover w-full h-full ' src={`${RPC_URL(__HOST__)}${data.games[selectedGame].path_covers[0]}`} />}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col gap-2 py-3 max-w-md'>
|
<div className='flex flex-col gap-2 py-3 max-w-md'>
|
||||||
<h1 className='font-semibold text-3xl text-shadow-md'>{game.name}</h1>
|
<h1 className='font-semibold text-3xl text-shadow-md'>{game.name}</h1>
|
||||||
|
|
@ -133,7 +139,7 @@ export function RouteComponent ()
|
||||||
<div className='pt-4'>
|
<div className='pt-4'>
|
||||||
<EmulatorsSection
|
<EmulatorsSection
|
||||||
id="recommended-emulators"
|
id="recommended-emulators"
|
||||||
onSelect={(id, focus) => storeContext.showDetails('emulator', 'store', id, focus)}
|
onSelect={(em, focus) => storeContext.showDetails('emulator', em.source, em.name, focus)}
|
||||||
onFocus={scrollIntoViewHandler({ block: 'end' })}
|
onFocus={scrollIntoViewHandler({ block: 'end' })}
|
||||||
emulators={recommendedEmulators} />
|
emulators={recommendedEmulators} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ function RouteComponent ()
|
||||||
{
|
{
|
||||||
if (type === 'emulator')
|
if (type === 'emulator')
|
||||||
{
|
{
|
||||||
|
if (source === 'local') return;
|
||||||
router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
router.navigate({ to: '/store/details/emulator/$id', params: { id } });
|
||||||
}
|
}
|
||||||
else if (type === 'game')
|
else if (type === 'game')
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,15 @@ export const getAllPluginsQuery = queryOptions({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getPluginDetailsQuery = (source: string) => queryOptions({
|
||||||
|
queryKey: ['plugins', source], queryFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await pluginsApi.plugins({ id: source }).get();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const enablePluginMutation = mutationOptions({
|
export const enablePluginMutation = mutationOptions({
|
||||||
mutationKey: ['plugin', 'enable'],
|
mutationKey: ['plugin', 'enable'],
|
||||||
mutationFn: async (vars: { id: string, enabled: boolean; }) =>
|
mutationFn: async (vars: { id: string, enabled: boolean; }) =>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
|
import { DefaultRommStaleTime, GameListFilterType, RommLoginDataSchema } from "@/shared/constants";
|
||||||
import { rommApi, settingsApi } from "../clientApi";
|
import { rommApi, settingsApi } from "../clientApi";
|
||||||
import { mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query";
|
import { InvalidateQueryFilters, mutationOptions, QueryFilters, queryOptions, useMutation } from "@tanstack/react-query";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
import { statsApiStatsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
|
||||||
|
|
||||||
|
|
@ -72,8 +72,8 @@ export const rommLoggedInQuery = queryOptions({
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
export const rommHostnameQuery = queryOptions({ queryKey: ['romm', 'auth', 'hostname'], queryFn: () => settingsApi.api.settings({ source: 'local' })({ id: 'rommAddress' }).get().then(d => d.data?.value as string) });
|
||||||
export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
export const rommUsernameQuery = queryOptions({ queryKey: ['romm', 'auth', 'username'], queryFn: () => settingsApi.api.settings({ source: 'local' })({ id: 'rommUser' }).get().then(d => d.data?.value as string) });
|
||||||
export const deleteGameMutation = (id: FrontEndId) => mutationOptions({
|
export const deleteGameMutation = (id: FrontEndId) => mutationOptions({
|
||||||
mutationKey: ['delete', id],
|
mutationKey: ['delete', id],
|
||||||
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
|
mutationFn: () => rommApi.api.romm.game({ source: id.source })({ id: id.id }).delete()
|
||||||
|
|
@ -107,9 +107,9 @@ export const platformQuery = (source: string, id: string) => queryOptions({
|
||||||
});
|
});
|
||||||
export const installMutation = (source: string, id: string) => mutationOptions({
|
export const installMutation = (source: string, id: string) => mutationOptions({
|
||||||
mutationKey: ['install', source, id],
|
mutationKey: ['install', source, id],
|
||||||
mutationFn: async () =>
|
mutationFn: async (init: { downloadId?: string; }) =>
|
||||||
{
|
{
|
||||||
const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post();
|
const { data, error } = await rommApi.api.romm.game({ source })({ id }).install.post({ query: { downloadId: init.downloadId } });
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
@ -171,3 +171,44 @@ export const fixSourceMutation = mutationOptions({
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
export const updateSourceMutation = mutationOptions({
|
||||||
|
mutationKey: ['game', "update_source"], mutationFn: async ({ source, id }: { source: string, id: string; }) =>
|
||||||
|
{
|
||||||
|
const { data, error } = await rommApi.api.romm.game({ source })({ id }).update.post();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export const updatePlatformMutation = (id: string) => mutationOptions({
|
||||||
|
mutationKey: ['platform', 'local', 'update', id],
|
||||||
|
mutationFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await rommApi.api.romm.platform.local({ id }).update.post();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export const deletePlatformMutation = (id: string) => mutationOptions({
|
||||||
|
mutationKey: ['platform', 'local', 'delete', id],
|
||||||
|
mutationFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await rommApi.api.romm.platform.local({ id }).delete();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export const localPlatformFilter = (id: string) => ({
|
||||||
|
predicate (query)
|
||||||
|
{
|
||||||
|
return query.queryKey.includes('platform') && ((query.queryKey.includes('local') && query.queryKey.includes(id)) || query.queryKey.includes('all'));
|
||||||
|
},
|
||||||
|
} satisfies InvalidateQueryFilters as InvalidateQueryFilters);
|
||||||
|
|
||||||
|
export const gameFiltersQuery = (filters: { source?: string; }) => queryOptions({
|
||||||
|
queryKey: ['game', 'filters', filters], queryFn: async () =>
|
||||||
|
{
|
||||||
|
const { data, error } = await rommApi.api.romm.games.filters.get({ query: { source: filters.source } });
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue