mirror of
https://github.com/Heretek-Games/drop-emulation.git
synced 2026-11-07 10:05:59 -10:00
feat: migrate launch customization to the RunnerProvider SPI (#7)
Replaces the pre-launch launch-command mutation with an EmulationRunnerProvider that returns LaunchOverrides (executable + arguments) through the game:runner SPI. Declares game:runner in class metadata and the shipped manifest (dropping the now-unused game:launch-hook), and bumps the SDK to ^0.7.0.
This commit is contained in:
1 parent
fe2b51068e
commit
6378073ecb
6 files changed
+1216
-78
No files matched your search
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sdk": "@droposs",
|
||||
"sdkVersion": "^0.6.1",
|
||||
"cliVersion": "^0.6.1"
|
||||
"sdkVersion": "^0.7.0",
|
||||
"cliVersion": "^0.7.0"
|
||||
}
|
||||
+14
-11
@@ -9,7 +9,7 @@
|
||||
"storageVersion": 1,
|
||||
"capabilities": [
|
||||
"ui:slot",
|
||||
"game:launch-hook",
|
||||
"game:runner",
|
||||
"system:command",
|
||||
"game:fs",
|
||||
"client:storage"
|
||||
@@ -22,7 +22,7 @@
|
||||
"entry": "dist/src/index.js",
|
||||
"capabilities": [
|
||||
"ui:slot",
|
||||
"game:launch-hook",
|
||||
"game:runner",
|
||||
"system:command",
|
||||
"game:fs",
|
||||
"client:storage"
|
||||
@@ -40,28 +40,31 @@
|
||||
"ppsspp-qt"
|
||||
]
|
||||
},
|
||||
"checksum": "9ce7c6ec4c4836739c276f928c96e1b01b23fc1021480e01ca9e9c8a6c663797",
|
||||
"checksum": "2746c9a322eebd173510d2640ad612a20400de99a1cb5b04c0994e9334b64817",
|
||||
"files": {
|
||||
".github/dependabot.yml": "fe156952d1952825377de8e48c90563a3ac19bb95a6e0ced8a2f498a0f20fdf6",
|
||||
".github/workflows/ci.yml": "60a33a67813097e4744a54b21d8af35e6c0254e56001123a69f34ccf65d1fa8b",
|
||||
".gitignore": "e8bc935ce727f1809c792f4c5d26c5fc75ba2b7df7fc9390c56c8892f6a6007f",
|
||||
".github/workflows/release.yml": "08a97b00cac41d321307cb894819c15d357300f950ec01cd51375a12e6604e10",
|
||||
".gitignore": "455edc14aa6727e0e1c9bf889ca4244f3334bfa8ee3b57af17cb83f9c395ea46",
|
||||
".sdk-scope.json": "f13936572aca04f0f47da440ed27eb69ea7d5c5b1d0901b69ebd4aa55ce455b6",
|
||||
"AGENTS.md": "5c40761f860317e9ac81fab0d7da939d579e48fec49b301f5aeac2de2b6c0ccf",
|
||||
"CLAUDE.md": "3b2c8a6a80fe79fa9ee0680b86d2e84ce0b9255d3dc43eccd4e2fce59064c7da",
|
||||
"dist/src/emulator.d.ts": "da93a753d5f334af5f3bcd24fe341dafeb7b43ac6ca9457436f40a21246341b7",
|
||||
"dist/src/emulator.js": "3d5de014c2b2e9783cb83adb0682708044cc01f1a9961da8c4f6749d63dcb7d1",
|
||||
"dist/src/index.d.ts": "8a71db2ff47abffdd62a70766f138d6a2899eeea0cb051e020fc5c0b27da17ff",
|
||||
"dist/src/index.js": "9ce7c6ec4c4836739c276f928c96e1b01b23fc1021480e01ca9e9c8a6c663797",
|
||||
"dist/src/index.d.ts": "3bff35db16ddcaa35cb637be050304cfeebbec266df8f587d899644de51bd31f",
|
||||
"dist/src/index.js": "2746c9a322eebd173510d2640ad612a20400de99a1cb5b04c0994e9334b64817",
|
||||
"dist/test/emulation.test.d.ts": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881",
|
||||
"dist/test/emulation.test.js": "f7d8596199e7367d86f4467e283850b4f0a53a7ddb1aa71b6f784f4c1e08460f",
|
||||
"dist/test/emulation.test.js": "8599e7e11d75997bf257efaf96daa83ae5d89081073a7e31dc6c0ddac732cb79",
|
||||
"eslint.config.mjs": "4cdf67d5a07b1a089d8f39e842a7de7532eb519752d60fe5ac706f42fb7bdc6f",
|
||||
"GEMINI.md": "3b2c8a6a80fe79fa9ee0680b86d2e84ce0b9255d3dc43eccd4e2fce59064c7da",
|
||||
"package-lock.json": "99c10be18f14b401afe4761eebee82a32e568a3b03108d7e21efdadabea25550",
|
||||
"package.json": "d1da6db7d790aadc5d088803ddf71585d2d0432a26225e9f97ec86bc8fab6e0a",
|
||||
"package-lock.json": "11dc51a4144e3043daf35528d416554f5521d239a1ca61ba7fcb89cf0f6f9fb9",
|
||||
"package.json": "b1e06b7306e8b3481bb01427a4c57f2cd33b44e455741862f630239e288211ba",
|
||||
"README.md": "398247026f1570e4165c67020d6d9e10c454fc59dc803235c27e756dc5c43b82",
|
||||
"scripts/switch-sdk-scope.mjs": "cee6513f66458bb65746bb4c27d749f0ab7d28bdb09634acbdda5e8acf4bda5b",
|
||||
"SECURITY.md": "1ed84c5e2293a1bda1e0d1650df39f5c03dba9a18ee6d8acff78cc10a6897d2d",
|
||||
"src/emulator.ts": "4b3c109bf0b0a1c6376745217febc259483acc8a58b3a767c3267165f9b40d5a",
|
||||
"src/index.ts": "977769733600a461bdc3413b10b4f7b9c1ac760835c67818e1fa6427e27aca0b",
|
||||
"test/emulation.test.ts": "b6e5be7b26bdfe702136ee092b6c09f08257997700677bc03eefe1f3025bbbd8",
|
||||
"src/index.ts": "bc8307afe2d1f2561b69e4fbb8d7a178f104d271ebfe21672f775b5b5a7696e6",
|
||||
"test/emulation.test.ts": "13e662becc251cd144b10fc00ee45aaf0d0863867f90021f5eda02416f1ae5bd",
|
||||
"tsconfig.json": "e67d6d62d5af3c653c4d1af30dea94d529c721708e54bd8609d748be627e79e5"
|
||||
}
|
||||
}
|
||||
Generated
+1117
-10
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -12,10 +12,10 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@droposs/plugin-sdk": "^0.6.1"
|
||||
"@droposs/plugin-sdk": "^0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@droposs/plugin-cli": "^0.6.1",
|
||||
"@droposs/plugin-cli": "^0.7.0",
|
||||
"typescript": "^5.7.2",
|
||||
"@types/node": "^22.0.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
+41
-29
@@ -2,6 +2,9 @@ import type {
|
||||
ClientPlugin,
|
||||
ClientPluginContext,
|
||||
LaunchContext,
|
||||
LaunchOverrides,
|
||||
RunnerPlatform,
|
||||
RunnerProvider,
|
||||
ScopedGameFs,
|
||||
ClientPluginSystem,
|
||||
ClientPluginStorage,
|
||||
@@ -10,7 +13,6 @@ import type {
|
||||
import {
|
||||
type EmulatorBinding,
|
||||
type EmulatorDefinition,
|
||||
type RomBindingMetadata,
|
||||
DEFAULT_EMULATORS,
|
||||
buildLaunchSpec,
|
||||
discoverEmulators,
|
||||
@@ -95,14 +97,14 @@ export interface ResolveLaunchDeps {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* emulator, and returns the `LaunchOverrides` the host applies to the launch
|
||||
* command. Returns `null` when the game has no binding. Throws to abort the
|
||||
* launch pipeline when the binding is broken.
|
||||
*/
|
||||
export async function resolveLaunch(
|
||||
export async function resolveLaunchOverrides(
|
||||
context: LaunchContext,
|
||||
deps: ResolveLaunchDeps,
|
||||
): Promise<RomBindingMetadata | null> {
|
||||
): Promise<LaunchOverrides | null> {
|
||||
const binding = await deps.storage.get<EmulatorBinding>(
|
||||
bindingKey(context.gameId),
|
||||
);
|
||||
@@ -132,20 +134,33 @@ export async function resolveLaunch(
|
||||
}
|
||||
|
||||
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;
|
||||
return { executable: spec.command, arguments: spec.args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility runner that rewrites a game's launch to its bound emulator.
|
||||
* Registered through the host `game:runner` SPI, so no pre-launch hook or core
|
||||
* launch-command mutation is involved.
|
||||
*/
|
||||
export class EmulationRunnerProvider implements RunnerProvider {
|
||||
id = "drop-emulation";
|
||||
name = "Emulation & ROM Linking";
|
||||
supportedPlatforms: RunnerPlatform[] = ["windows", "linux", "macos", "rom"];
|
||||
|
||||
constructor(private readonly deps: ResolveLaunchDeps) {}
|
||||
|
||||
async detect(): Promise<{ available: boolean; version?: string }> {
|
||||
// Availability is per-game (the ROM binding); the host calls resolveLaunch
|
||||
// for every game and we contribute overrides only when one exists.
|
||||
return { available: true };
|
||||
}
|
||||
|
||||
async resolveLaunch(context: LaunchContext): Promise<LaunchOverrides> {
|
||||
return (await resolveLaunchOverrides(context, this.deps)) ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
export default class EmulationPlugin implements ClientPlugin {
|
||||
@@ -156,7 +171,7 @@ export default class EmulationPlugin implements ClientPlugin {
|
||||
apiVersion: 2,
|
||||
capabilities: [
|
||||
"ui:slot" as const,
|
||||
"game:launch-hook" as const,
|
||||
"game:runner" as const,
|
||||
"system:command" as const,
|
||||
"game:fs" as const,
|
||||
"client:storage" as const,
|
||||
@@ -164,17 +179,14 @@ export default class EmulationPlugin implements ClientPlugin {
|
||||
};
|
||||
|
||||
async init(ctx: ClientPluginContext): Promise<void> {
|
||||
ctx.registerLaunchHook({
|
||||
stage: "pre-launch:prepare",
|
||||
execute: async (context: LaunchContext) => {
|
||||
await resolveLaunch(context, {
|
||||
gameFs: ctx.gameFs,
|
||||
system: ctx.system,
|
||||
storage: ctx.storage,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
ctx.registerRunnerProvider?.(
|
||||
new EmulationRunnerProvider({
|
||||
gameFs: ctx.gameFs,
|
||||
system: ctx.system,
|
||||
storage: ctx.storage,
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
);
|
||||
|
||||
ctx.registerSlot("game-detail:panels", createEmulatorPanel(ctx), {
|
||||
order: 40,
|
||||
|
||||
+40
-24
@@ -9,13 +9,13 @@ import Plugin, {
|
||||
createEmulatorPanel,
|
||||
discoverEmulators,
|
||||
expandRomArgs,
|
||||
resolveLaunch,
|
||||
resolveLaunchOverrides,
|
||||
tokenizeCommand,
|
||||
} from "../src/index.js";
|
||||
|
||||
const CAPABILITIES = [
|
||||
"ui:slot",
|
||||
"game:launch-hook",
|
||||
"game:runner",
|
||||
"system:command",
|
||||
"game:fs",
|
||||
"client:storage",
|
||||
@@ -42,13 +42,14 @@ function deps(ctx: MockClientPluginContext): ResolveLaunchDeps {
|
||||
};
|
||||
}
|
||||
|
||||
test("drop-emulation registers a launch hook and a game-detail panel", async () => {
|
||||
test("drop-emulation registers a runner provider 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");
|
||||
assert.equal(ctx.launchHooks.length, 0);
|
||||
assert.equal(ctx.runnerProviders.length, 1);
|
||||
assert.equal(ctx.runnerProviders[0].id, "drop-emulation");
|
||||
const panels = ctx.registeredSlots.get("game-detail:panels");
|
||||
assert.equal(panels?.length, 1);
|
||||
assert.equal((panels?.[0].component as { name?: string }).name, "EmulatorPanel");
|
||||
@@ -127,47 +128,62 @@ test("discoverEmulators keeps only allowlisted, working binaries", async () => {
|
||||
assert.deepEqual(available.map((entry) => entry.id), ["retroarch"]);
|
||||
});
|
||||
|
||||
test("resolveLaunch validates the ROM and rewrites the launch command", async () => {
|
||||
test("resolveLaunchOverrides validates the ROM and returns launch overrides", 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",
|
||||
const overrides = await resolveLaunchOverrides(
|
||||
structuredClone(LAUNCH),
|
||||
deps(ctx),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(context.metadata?.emulation as { romPath?: string }).romPath,
|
||||
assert.ok(overrides);
|
||||
assert.equal(overrides.executable, "retroarch");
|
||||
assert.deepEqual(overrides.arguments, [
|
||||
"-L",
|
||||
"snes9x",
|
||||
"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 () => {
|
||||
test("EmulationRunnerProvider contributes overrides through the runner SPI", async () => {
|
||||
const ctx = new MockClientPluginContext("drop-emulation", [
|
||||
...CAPABILITIES,
|
||||
]);
|
||||
await new Plugin().init(ctx);
|
||||
await ctx.storage.set("binding:g1", BINDING);
|
||||
await ctx.gameFs.writeFile("g1", BINDING.romPath, "rom-bytes");
|
||||
|
||||
const provider = ctx.runnerProviders[0];
|
||||
assert.deepEqual(await provider.detect(), { available: true });
|
||||
const overrides = await provider.resolveLaunch(structuredClone(LAUNCH));
|
||||
assert.equal(overrides.executable, "retroarch");
|
||||
assert.deepEqual(overrides.arguments, [
|
||||
"-L",
|
||||
"snes9x",
|
||||
"roms/chrono-trigger.sfc",
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveLaunchOverrides 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(await resolveLaunchOverrides(context, deps(ctx)), null);
|
||||
assert.equal(context.metadata, undefined);
|
||||
|
||||
await ctx.storage.set("binding:g1", BINDING);
|
||||
await assert.rejects(
|
||||
() => resolveLaunch(structuredClone(LAUNCH), deps(ctx)),
|
||||
() => resolveLaunchOverrides(structuredClone(LAUNCH), deps(ctx)),
|
||||
/was not found in the game directory/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveLaunch rejects emulators that are not installed or allowlisted", async () => {
|
||||
test("resolveLaunchOverrides rejects emulators that are not installed or allowlisted", async () => {
|
||||
const ctx = new MockClientPluginContext("drop-emulation", [
|
||||
...CAPABILITIES,
|
||||
]);
|
||||
@@ -179,7 +195,7 @@ test("resolveLaunch rejects emulators that are not installed or allowlisted", as
|
||||
stderr: "command not allowlisted",
|
||||
});
|
||||
await assert.rejects(
|
||||
() => resolveLaunch(structuredClone(LAUNCH), deps(ctx)),
|
||||
() => resolveLaunchOverrides(structuredClone(LAUNCH), deps(ctx)),
|
||||
/not installed or allowlisted/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user