Merge pull request #1 from Heretek-Games/chore/sdk-ci

feat(launch): ROM binding validation + command rewrite + registry SDK/CI baseline
This commit is contained in:
BillyOutlast authored and GitHub committed 2026-09-14 09:14:28 -04:00
commit 03b4cd4e9d
9 files changed
+1154 -58

No files matched your search

+32
View File
@@ -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
+6 -5
View File
@@ -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`).
+23 -1
View File
@@ -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:<gameId>` 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
```
+23 -1
View File
@@ -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"
]
}
}
+561 -33
View File
@@ -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",
+3 -2
View File
@@ -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"
},
+155
View File
@@ -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<EmulatorDefinition[]> {
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;
}
+158 -12
View File
@@ -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<ClientPluginContext, "storage">) {
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<EmulatorBinding>(
bindingKey(gameId),
);
} catch (error) {
this.error = (error as Error).message;
} finally {
this.loading = false;
}
},
template: `
<div class="rounded-xl border border-zinc-800 bg-zinc-900/60 p-4">
<h4 class="text-xs font-semibold text-zinc-200">Emulator</h4>
<p v-if="loading" class="text-xs text-zinc-400 mt-1">Checking ROM binding&hellip;</p>
<p v-else-if="error" class="text-xs text-red-400 mt-1">{{ error }}</p>
<template v-else-if="binding">
<p class="text-xs text-zinc-400 mt-1">
Emulator: <span class="font-mono text-zinc-200">{{ binding.emulatorId }}</span>
</p>
<p v-if="binding.coreId" class="text-xs text-zinc-400 mt-1">
Core: <span class="font-mono text-zinc-200">{{ binding.coreId }}</span>
</p>
<p class="text-xs text-zinc-400 mt-1">
ROM: <span class="font-mono text-zinc-200">{{ binding.romPath }}</span>
</p>
</template>
<p v-else class="text-xs text-zinc-400 mt-1">No ROM binding configured for this game.</p>
</div>
`,
};
}
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<RomBindingMetadata | null> {
const binding = await deps.storage.get<EmulatorBinding>(
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<EmulatorDefinition[]>(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<void> {
ctx.registerLaunchHook({
stage: "pre-launch:prepare",
execute: async (context: LaunchContext) => {
const binding = await ctx.storage.get<EmulatorBinding>(`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");
}
}
+193 -4
View File
@@ -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);
});