diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd3d723 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/AGENTS.md b/AGENTS.md index 11a8f05..7498e45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,10 +5,11 @@ Emulator and ROM linking plugin for Drop (#10, #189). ## Commands ```sh -pnpm install -pnpm build -pnpm test +npm ci +npm run build +npm test +npm run typecheck ``` -Built on `@droposs/plugin-sdk` (plugin API v2); the dependency resolves the -sibling checkout at `../drop-plugin-sdk/packages/plugin-sdk`. +Built on `@droposs/plugin-sdk` (plugin API v2), resolved from the npm registry +(currently `^0.4.0`). diff --git a/README.md b/README.md index fd3b0c6..9351ed7 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,30 @@ Emulator and ROM linking plugin for Drop (#10, #189). +## How it works + +Per-game bindings are stored under the client storage key +`binding:` as `{ emulatorId, coreId, romPath, command? }`. Emulator +definitions live under the `emulators` key (falling back to the built-in list +in `src/emulator.ts`). + +During the `pre-launch:prepare` stage the plugin: + +1. Looks up the game's binding (no-op when unset). +2. Validates the ROM exists through the scoped `game:fs` API. +3. Probes configured emulators through the allowlisted `system:command` runner + and drops anything missing or failing (`--version` probe). +4. Rewrites the launch command, expanding `{core}` and `{rom_path}` in the + template, and publishes `metadata.launchCommand` plus + `metadata.emulation` on the launch context. + +A `game-detail:panels` slot (`EmulatorPanel`) shows the configured binding. + ## Build ```sh -pnpm install && pnpm build && pnpm test +npm ci +npm run build +npm test +npm run typecheck ``` diff --git a/drop-plugin.json b/drop-plugin.json index e306cd8..86ab51a 100644 --- a/drop-plugin.json +++ b/drop-plugin.json @@ -17,5 +17,27 @@ "targets": [ "client" ], - "entry": "dist/src/index.js" + "entry": "dist/src/index.js", + "client": { + "entry": "dist/src/index.js", + "capabilities": [ + "ui:slot", + "game:launch-hook", + "system:command", + "game:fs", + "client:storage" + ], + "commands": [ + "retroarch", + "dolphin-emu", + "duckstation-qt", + "pcsx2-qt", + "melonds", + "mgba-qt", + "snes9x-gtk", + "mupen64plus", + "flycast", + "ppsspp-qt" + ] + } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index daddcf1..96f247a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,48 +9,476 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@droposs/plugin-sdk": "file:../drop-plugin-sdk/packages/plugin-sdk" + "@droposs/plugin-sdk": "^0.4.0" }, "devDependencies": { - "@droposs/plugin-cli": "file:../drop-plugin-sdk/packages/plugin-cli", - "@types/node": "^22.0.0", - "typescript": "^5.7.0" - } - }, - "../drop-plugin-sdk/packages/plugin-cli": { - "name": "@droposs/plugin-cli", - "version": "0.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@droposs/plugin-sdk": "workspace:*", - "ajv": "^8.20.0", - "esbuild": "^0.28.2" - }, - "bin": { - "drop-plugin": "bin/drop-plugin.js" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.7.0" - } - }, - "../drop-plugin-sdk/packages/plugin-sdk": { - "name": "@droposs/plugin-sdk", - "version": "0.3.0", - "license": "MIT", - "devDependencies": { + "@droposs/plugin-cli": "^0.4.0", "@types/node": "^22.0.0", "typescript": "^5.7.0" } }, "node_modules/@droposs/plugin-cli": { - "resolved": "../drop-plugin-sdk/packages/plugin-cli", - "link": true + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@droposs/plugin-cli/-/plugin-cli-0.4.0.tgz", + "integrity": "sha512-3gF/Z3Klf3CLBCtLpPdrzX46G/R+tkMQ9EYtUODD8aRm99jh/dkfalIxVCPQXsTGOph/06UbmJQa2gw9mZ/lag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@droposs/plugin-sdk": "0.4.0", + "ajv": "^8.20.0", + "esbuild": "^0.28.2" + }, + "bin": { + "drop-plugin": "bin/drop-plugin.js" + } }, "node_modules/@droposs/plugin-sdk": { - "resolved": "../drop-plugin-sdk/packages/plugin-sdk", - "link": true + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@droposs/plugin-sdk/-/plugin-sdk-0.4.0.tgz", + "integrity": "sha512-BN9qhONnZPURAzvK0qCnO++5Ie5VenIY6G0kJvP141WfJI/72+a/2ojuKQsGKchSIYo9qnVIHrdmkMB3+GnzhQ==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, "node_modules/@types/node": { "version": "22.20.2", @@ -62,6 +490,106 @@ "undici-types": "~6.21.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 6bddfc6..b7371d4 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,14 @@ "scripts": { "build": "rm -rf dist && tsc", "test": "node --test dist/test/*.js", + "typecheck": "tsc --noEmit", "sign": "drop-plugin sign ." }, "dependencies": { - "@droposs/plugin-sdk": "file:../drop-plugin-sdk/packages/plugin-sdk" + "@droposs/plugin-sdk": "^0.4.0" }, "devDependencies": { - "@droposs/plugin-cli": "file:../drop-plugin-sdk/packages/plugin-cli", + "@droposs/plugin-cli": "^0.4.0", "typescript": "^5.7.0", "@types/node": "^22.0.0" }, diff --git a/src/emulator.ts b/src/emulator.ts new file mode 100644 index 0000000..9f73f54 --- /dev/null +++ b/src/emulator.ts @@ -0,0 +1,155 @@ +import type { ClientPluginSystem } from "@droposs/plugin-sdk"; + +export interface EmulatorDefinition { + /** Bare executable name; must be allowlisted in `client.commands`. */ + id: string; + name: string; + /** Command template, e.g. `retroarch -L {core} {rom_path}`. */ + command: string; + /** Extra argument templates appended after the command template. */ + args?: string[]; + /** Probe arguments used to detect an installed emulator. */ + probeArgs?: string[]; +} + +export interface EmulatorBinding { + /** Emulator definition id to launch. */ + emulatorId: string; + /** Libretro core id (used by RetroArch-style `{core}` templates). */ + coreId: string; + /** ROM path relative to the game directory. */ + romPath: string; + /** Optional per-binding command template override. */ + command?: string; +} + +export interface LaunchSpec { + emulatorId: string; + /** Resolved executable name. */ + command: string; + /** Expanded argument list. */ + args: string[]; + /** Display form of the full command line. */ + commandLine: string; +} + +export interface RomBindingMetadata extends LaunchSpec { + romPath: string; + coreId: string; +} + +export const DEFAULT_PROBE_ARGS = ["--version"]; + +/** Emulators registered out of the box; keep in sync with `client.commands`. */ +export const DEFAULT_EMULATORS: EmulatorDefinition[] = [ + { id: "retroarch", name: "RetroArch", command: "retroarch -L {core} {rom_path}" }, + { id: "dolphin-emu", name: "Dolphin", command: "dolphin-emu -e {rom_path}" }, + { id: "duckstation-qt", name: "DuckStation", command: "duckstation-qt {rom_path}" }, + { id: "pcsx2-qt", name: "PCSX2", command: "pcsx2-qt {rom_path}" }, + { id: "melonds", name: "melonDS", command: "melonds {rom_path}" }, + { id: "mgba-qt", name: "mGBA", command: "mgba-qt {rom_path}" }, + { id: "snes9x-gtk", name: "Snes9x", command: "snes9x-gtk {rom_path}" }, + { id: "mupen64plus", name: "Mupen64Plus", command: "mupen64plus {rom_path}" }, + { id: "flycast", name: "Flycast", command: "flycast {rom_path}" }, + { id: "ppsspp-qt", name: "PPSSPP", command: "ppsspp-qt {rom_path}" }, +]; + +/** Expands the ROM placeholders in an emulator launch command. */ +export function expandRomArgs(command: string, binding: EmulatorBinding): string { + return command + .replaceAll("{rom_path}", binding.romPath) + .replaceAll("{core}", binding.coreId); +} + +/** + * Splits a command template into tokens. Supports single/double quotes and + * backslash escapes so paths containing spaces survive the rewrite. + */ +export function tokenizeCommand(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + let quoted = false; + for (let index = 0; index < command.length; index += 1) { + const char = command[index]; + if (quote) { + if (char === quote) { + quote = null; + } else if ( + char === "\\" && + index + 1 < command.length && + (command[index + 1] === quote || command[index + 1] === "\\") + ) { + index += 1; + current += command[index]; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + quote = char; + quoted = true; + } else if (/\s/.test(char)) { + if (current.length > 0 || quoted) { + tokens.push(current); + current = ""; + quoted = false; + } + } else { + current += char; + } + } + if (quote) throw new Error("Unterminated quote in emulator command"); + if (current.length > 0 || quoted) tokens.push(current); + return tokens; +} + +/** + * Rewrites a binding into a concrete launch spec: expands placeholders in the + * template (and any extra args) and splits the result into command + argv. + */ +export function buildLaunchSpec( + definition: EmulatorDefinition, + binding: EmulatorBinding, +): LaunchSpec { + const template = binding.command ?? definition.command; + const expanded = expandRomArgs(template, binding); + const tokens = tokenizeCommand(expanded); + const [command, ...args] = tokens; + if (!command) { + throw new Error(`Emulator '${definition.id}' has an empty command template`); + } + const extraArgs = (definition.args ?? []).map((arg) => + expandRomArgs(arg, binding), + ); + return { + emulatorId: definition.id, + command, + args: [...args, ...extraArgs], + commandLine: [command, ...args, ...extraArgs].join(" "), + }; +} + +/** + * Probes configured emulators through the client host's allowlisted command + * runner. Emulators that are missing, not allowlisted, or exit non-zero are + * omitted so a broken binding fails before launch. + */ +export async function discoverEmulators( + definitions: EmulatorDefinition[], + system: ClientPluginSystem, +): Promise { + const available: EmulatorDefinition[] = []; + for (const definition of definitions) { + try { + const result = await system.run( + definition.id, + definition.probeArgs ?? DEFAULT_PROBE_ARGS, + { timeoutMs: 5_000 }, + ); + if (result.code === 0) available.push(definition); + } catch { + // Not installed or not allowlisted: skip. + } + } + return available; +} diff --git a/src/index.ts b/src/index.ts index d30ee49..26cd65f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,16 +2,150 @@ import type { ClientPlugin, ClientPluginContext, LaunchContext, + ScopedGameFs, + ClientPluginSystem, + ClientPluginStorage, + PluginLogger, } from "@droposs/plugin-sdk"; +import { + type EmulatorBinding, + type EmulatorDefinition, + type RomBindingMetadata, + DEFAULT_EMULATORS, + buildLaunchSpec, + discoverEmulators, +} from "./emulator.js"; -export interface EmulatorBinding { - coreId: string; - romPath: string; +export * from "./emulator.js"; + +export const BINDING_KEY_PREFIX = "binding:"; +export const EMULATORS_KEY = "emulators"; + +export function bindingKey(gameId: string): string { + return `${BINDING_KEY_PREFIX}${gameId}`; } -/** Expands the ROM placeholder in an emulator launch command. */ -export function expandRomArgs(command: string, binding: EmulatorBinding): string { - return command.replaceAll("{rom_path}", binding.romPath).replaceAll("{core}", binding.coreId); +interface EmulatorPanelThis { + game?: { id?: string; title?: string }; + binding: EmulatorBinding | null; + loading: boolean; + error: string; +} + +/** + * Game-detail panel component. Uses the options API (no `vue` import) so the + * plugin bundle stays framework-agnostic; the host renders `template` inside + * the `game-detail:panels` slot. + */ +export function createEmulatorPanel(ctx: Pick) { + return { + name: "EmulatorPanel", + props: { game: { type: Object, required: false } }, + data(): { + binding: EmulatorBinding | null; + loading: boolean; + error: string; + } { + return { binding: null, loading: true, error: "" }; + }, + async mounted(this: EmulatorPanelThis) { + const gameId = this.game?.id; + if (!gameId) { + this.loading = false; + return; + } + try { + this.binding = await ctx.storage.get( + bindingKey(gameId), + ); + } catch (error) { + this.error = (error as Error).message; + } finally { + this.loading = false; + } + }, + template: ` +
+

Emulator

+

Checking ROM binding…

+

{{ error }}

+ +

No ROM binding configured for this game.

+
+ `, + }; +} + +export interface ResolveLaunchDeps { + gameFs: ScopedGameFs; + system: ClientPluginSystem; + storage: ClientPluginStorage; + logger: PluginLogger; +} + +/** + * Validates the game's ROM binding, discovers an installed/allowlisted + * emulator, and publishes the rewritten launch command on + * `context.metadata.launchCommand` (plus `context.metadata.emulation`). + * Throws to abort the launch pipeline when the binding is broken. + */ +export async function resolveLaunch( + context: LaunchContext, + deps: ResolveLaunchDeps, +): Promise { + const binding = await deps.storage.get( + bindingKey(context.gameId), + ); + if (!binding) return null; + + const romExists = await deps.gameFs.fileExists( + context.gameId, + binding.romPath, + ); + if (!romExists) { + throw new Error( + `ROM '${binding.romPath}' was not found in the game directory`, + ); + } + + const definitions = + (await deps.storage.get(EMULATORS_KEY)) ?? + DEFAULT_EMULATORS; + const available = await discoverEmulators(definitions, deps.system); + const definition = available.find( + (entry) => entry.id === binding.emulatorId, + ); + if (!definition) { + throw new Error( + `Emulator '${binding.emulatorId}' is not installed or allowlisted`, + ); + } + + const spec = buildLaunchSpec(definition, binding); + const metadata: RomBindingMetadata = { + ...spec, + romPath: binding.romPath, + coreId: binding.coreId, + }; + context.metadata = { + ...(context.metadata ?? {}), + launchCommand: spec.commandLine, + emulation: metadata, + }; + deps.logger.debug( + `ROM binding ready for ${context.gameTitle}: ${spec.commandLine}`, + ); + return metadata; } export default class EmulationPlugin implements ClientPlugin { @@ -19,21 +153,33 @@ export default class EmulationPlugin implements ClientPlugin { id: "drop-emulation", name: "Emulation & ROM Linking", version: "0.1.0", + apiVersion: 2, + capabilities: [ + "ui:slot" as const, + "game:launch-hook" as const, + "system:command" as const, + "game:fs" as const, + "client:storage" as const, + ], }; async init(ctx: ClientPluginContext): Promise { ctx.registerLaunchHook({ stage: "pre-launch:prepare", execute: async (context: LaunchContext) => { - const binding = await ctx.storage.get(`binding:${context.gameId}`); - if (!binding) return; - ctx.logger.debug( - `ROM binding active for ${context.gameTitle}: ${expandRomArgs("{rom_path}", binding)}`, - ); + await resolveLaunch(context, { + gameFs: ctx.gameFs, + system: ctx.system, + storage: ctx.storage, + logger: ctx.logger, + }); }, }); - ctx.registerSlot("game-detail:panels", { template: "EmulatorPanel" }); + ctx.registerSlot("game-detail:panels", createEmulatorPanel(ctx), { + order: 40, + label: "Emulator", + }); ctx.logger.info("Emulation plugin initialized"); } } diff --git a/test/emulation.test.ts b/test/emulation.test.ts index 3a8c5c0..f048bca 100644 --- a/test/emulation.test.ts +++ b/test/emulation.test.ts @@ -1,16 +1,205 @@ import test from "node:test"; import assert from "node:assert/strict"; import { MockClientPluginContext } from "@droposs/plugin-sdk"; -import Plugin, { expandRomArgs } from "../src/index.js"; +import type { LaunchContext } from "@droposs/plugin-sdk"; +import Plugin, { + type EmulatorBinding, + type ResolveLaunchDeps, + buildLaunchSpec, + createEmulatorPanel, + discoverEmulators, + expandRomArgs, + resolveLaunch, + tokenizeCommand, +} from "../src/index.js"; -test("drop-emulation registers a launch hook and panel", async () => { - const ctx = new MockClientPluginContext("drop-emulation", ["ui:slot", "game:launch-hook", "system:command", "game:fs", "client:storage"]); +const CAPABILITIES = [ + "ui:slot", + "game:launch-hook", + "system:command", + "game:fs", + "client:storage", +] as const; + +const BINDING: EmulatorBinding = { + emulatorId: "retroarch", + coreId: "snes9x", + romPath: "roms/chrono-trigger.sfc", +}; + +const LAUNCH: LaunchContext = { + gameId: "g1", + gameTitle: "Chrono Trigger", + gameDir: "/games/ct", +}; + +function deps(ctx: MockClientPluginContext): ResolveLaunchDeps { + return { + gameFs: ctx.gameFs, + system: ctx.system, + storage: ctx.storage, + logger: ctx.logger, + }; +} + +test("drop-emulation registers a launch hook and a game-detail panel", async () => { + const ctx = new MockClientPluginContext("drop-emulation", [ + ...CAPABILITIES, + ]); await new Plugin().init(ctx); assert.equal(ctx.launchHooks.length, 1); + assert.equal(ctx.launchHooks[0].stage, "pre-launch:prepare"); + const panels = ctx.registeredSlots.get("game-detail:panels"); + assert.equal(panels?.length, 1); + assert.equal((panels?.[0].component as { name?: string }).name, "EmulatorPanel"); + assert.equal(panels?.[0].label, "Emulator"); }); test("expandRomArgs substitutes placeholders", () => { const command = "retroarch -L {core} {rom_path}"; - const expanded = expandRomArgs(command, { coreId: "snes9x", romPath: "/roms/game.sfc" }); + const expanded = expandRomArgs(command, { + emulatorId: "retroarch", + coreId: "snes9x", + romPath: "/roms/game.sfc", + }); assert.equal(expanded, "retroarch -L snes9x /roms/game.sfc"); }); + +test("tokenizeCommand respects quotes and escapes", () => { + assert.deepEqual(tokenizeCommand('retroarch -L "{core}" "{rom_path}"'), [ + "retroarch", + "-L", + "{core}", + "{rom_path}", + ]); + assert.deepEqual( + tokenizeCommand('"C:\\Program Files\\emu.exe" --rom "my rom.sfc" --flag'), + ["C:\\Program Files\\emu.exe", "--rom", "my rom.sfc", "--flag"], + ); + assert.deepEqual(tokenizeCommand('emu ""'), ["emu", ""]); + assert.throws(() => tokenizeCommand('emu "unterminated')); +}); + +test("buildLaunchSpec produces the rewritten command line", () => { + const spec = buildLaunchSpec( + { + id: "retroarch", + name: "RetroArch", + command: 'retroarch -L "{core}" "{rom_path}"', + args: ["--verbose"], + }, + { + emulatorId: "retroarch", + coreId: "snes9x", + romPath: "/roms/Chrono Trigger.sfc", + }, + ); + assert.equal(spec.command, "retroarch"); + assert.deepEqual(spec.args, [ + "-L", + "snes9x", + "/roms/Chrono Trigger.sfc", + "--verbose", + ]); + assert.equal( + spec.commandLine, + "retroarch -L snes9x /roms/Chrono Trigger.sfc --verbose", + ); +}); + +test("discoverEmulators keeps only allowlisted, working binaries", async () => { + const ctx = new MockClientPluginContext("drop-emulation", [ + ...CAPABILITIES, + ]); + ctx.systemCommand.fallback = { code: 1, stdout: "", stderr: "not found" }; + ctx.systemCommand.setResponse("retroarch", ["--version"], { + code: 0, + stdout: "RetroArch 1.19", + stderr: "", + }); + const available = await discoverEmulators( + [ + { id: "retroarch", name: "RetroArch", command: "retroarch {rom_path}" }, + { id: "dolphin-emu", name: "Dolphin", command: "dolphin-emu {rom_path}" }, + ], + ctx.system, + ); + assert.deepEqual(available.map((entry) => entry.id), ["retroarch"]); +}); + +test("resolveLaunch validates the ROM and rewrites the launch command", async () => { + const ctx = new MockClientPluginContext("drop-emulation", [ + ...CAPABILITIES, + ]); + await ctx.storage.set("binding:g1", BINDING); + await ctx.gameFs.writeFile("g1", BINDING.romPath, "rom-bytes"); + + const metadata = await resolveLaunch(structuredClone(LAUNCH), deps(ctx)); + assert.ok(metadata); + assert.equal(metadata.command, "retroarch"); + assert.deepEqual(metadata.args, ["-L", "snes9x", "roms/chrono-trigger.sfc"]); + + const context = structuredClone(LAUNCH); + await resolveLaunch(context, deps(ctx)); + assert.equal( + context.metadata?.launchCommand, + "retroarch -L snes9x roms/chrono-trigger.sfc", + ); + assert.deepEqual( + (context.metadata?.emulation as { romPath?: string }).romPath, + "roms/chrono-trigger.sfc", + ); + assert.deepEqual(ctx.systemCommand.calls[0].args, ["--version"]); +}); + +test("resolveLaunch is a no-op without a binding and aborts on a missing ROM", async () => { + const ctx = new MockClientPluginContext("drop-emulation", [ + ...CAPABILITIES, + ]); + const context = structuredClone(LAUNCH); + assert.equal(await resolveLaunch(context, deps(ctx)), null); + assert.equal(context.metadata, undefined); + + await ctx.storage.set("binding:g1", BINDING); + await assert.rejects( + () => resolveLaunch(structuredClone(LAUNCH), deps(ctx)), + /was not found in the game directory/, + ); +}); + +test("resolveLaunch rejects emulators that are not installed or allowlisted", async () => { + const ctx = new MockClientPluginContext("drop-emulation", [ + ...CAPABILITIES, + ]); + await ctx.storage.set("binding:g1", BINDING); + await ctx.gameFs.writeFile("g1", BINDING.romPath, "rom-bytes"); + ctx.systemCommand.setResponse("retroarch", ["--version"], { + code: 1, + stdout: "", + stderr: "command not allowlisted", + }); + await assert.rejects( + () => resolveLaunch(structuredClone(LAUNCH), deps(ctx)), + /not installed or allowlisted/, + ); +}); + +test("the EmulatorPanel component reads the binding from storage", async () => { + const ctx = new MockClientPluginContext("drop-emulation", [ + ...CAPABILITIES, + ]); + await ctx.storage.set("binding:g1", BINDING); + const panel = createEmulatorPanel(ctx); + assert.equal(panel.name, "EmulatorPanel"); + assert.match(panel.template, /game-detail|Emulator/); + assert.match(panel.template, /binding\.romPath/); + + const state = panel.data(); + const self = { ...state, game: { id: "g1" } } as typeof state & { + game: { id: string }; + }; + await panel.mounted.call(self as never); + assert.equal(self.loading, false); + assert.equal(self.error, ""); + assert.equal(self.binding?.romPath, BINDING.romPath); +});