@nataliapc/mcp-openmsx 1.2.6 → 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/openmsx.js +497 -226
- package/dist/server.js +11 -4
- package/dist/server_tools.js +52 -15
- package/dist/utils.js +56 -1
- package/package.json +12 -3
package/README.md
CHANGED
|
@@ -241,7 +241,7 @@ Edit it to include the following JSON entry:
|
|
|
241
241
|
|
|
242
242
|
| Variable | Description | Default Value | Example |
|
|
243
243
|
|----------|-------------|---------------|---------|
|
|
244
|
-
| `OPENMSX_EXECUTABLE` | Path or command to the openMSX executable | `openmsx` (Linux/macOS)
|
|
244
|
+
| `OPENMSX_EXECUTABLE` | Path or command to the openMSX executable | Auto-detected: `openmsx` (Linux), `/Applications/openMSX.app/Contents/MacOS/openmsx` (macOS), `openmsx.exe` (Windows) | `/usr/local/bin/openmsx` or `C:\Program Files\openMSX\openmsx.exe` |
|
|
245
245
|
| `OPENMSX_SHARE_DIR` | Directory containing openMSX data files (machines, extensions, etc.) | System dependent | `/home/myuser/.openmsx/share` |
|
|
246
246
|
| `OPENMSX_SCREENSHOT_DIR` | Directory where screenshots will be saved | Default for openmsx | `/myproject/screenshots` |
|
|
247
247
|
| `OPENMSX_SCREENDUMP_DIR` | Directory where screen dumps will be saved | Default for openmsx | `/myproject/screendumps` |
|
package/dist/openmsx.js
CHANGED
|
@@ -5,27 +5,39 @@
|
|
|
5
5
|
* @license GPL2
|
|
6
6
|
*/
|
|
7
7
|
import fs from "fs/promises";
|
|
8
|
+
import fsSync from "fs";
|
|
8
9
|
import { extractDescriptionFromXML, decodeHtmlEntities, encodeHtmlEntities } from "./utils.js";
|
|
9
10
|
import { spawn } from 'child_process';
|
|
11
|
+
import net from 'net';
|
|
12
|
+
import os from 'os';
|
|
10
13
|
import path from 'path';
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
*/
|
|
14
|
+
/** True when running on Windows. Evaluated once at module load. */
|
|
15
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
14
16
|
export class OpenMSX {
|
|
15
17
|
lastMachine = null;
|
|
16
18
|
process = null;
|
|
17
19
|
isConnected = false;
|
|
20
|
+
// Windows TCP socket — bidirectional after SSPI auth
|
|
21
|
+
tcpSocket = null;
|
|
22
|
+
// Accumulated I/O data for readData() — shared by both platforms (never coexist)
|
|
23
|
+
ioBuffer = '';
|
|
24
|
+
// Notify callback: fired when new I/O data arrives — shared by both platforms
|
|
25
|
+
ioNotify = null;
|
|
26
|
+
// Serial command queue — ensures sendCommand calls never overlap on any platform
|
|
27
|
+
commandQueue = Promise.resolve('');
|
|
18
28
|
/**
|
|
19
|
-
* Launch the openMSX emulator
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* @returns Promise that resolves when the emulator is ready
|
|
29
|
+
* Launch the openMSX emulator.
|
|
30
|
+
* Linux/macOS: -control stdio via stdin/stdout pipes.
|
|
31
|
+
* Windows: spawn with ignore+ignore+pipe, TCP socket + SSPI auth.
|
|
23
32
|
*/
|
|
24
33
|
async emu_launch(executable, machine, extensions) {
|
|
25
34
|
return new Promise((resolve) => {
|
|
26
35
|
let resolved = false;
|
|
27
|
-
|
|
28
|
-
const
|
|
36
|
+
const diagLog = [];
|
|
37
|
+
const diag = (msg) => {
|
|
38
|
+
console.error(`[mcp-openmsx] ${msg}`);
|
|
39
|
+
diagLog.push(msg);
|
|
40
|
+
};
|
|
29
41
|
const safeResolve = (message) => {
|
|
30
42
|
if (!resolved) {
|
|
31
43
|
resolved = true;
|
|
@@ -33,108 +45,123 @@ export class OpenMSX {
|
|
|
33
45
|
}
|
|
34
46
|
};
|
|
35
47
|
try {
|
|
36
|
-
// Check if emulator is already running
|
|
37
48
|
if (this.process && !this.process.killed) {
|
|
38
|
-
safeResolve(`Error: openMSX emulator instance is already running (
|
|
49
|
+
safeResolve(`Error: openMSX emulator instance is already running (current machine: ${this.lastMachine}). Close it first.`);
|
|
39
50
|
return;
|
|
40
51
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
52
|
+
this.resetIO();
|
|
53
|
+
this.commandQueue = Promise.resolve(''); // reset queue for new session
|
|
54
|
+
// Build args
|
|
55
|
+
const args = [];
|
|
56
|
+
if (!IS_WINDOWS) {
|
|
57
|
+
args.push('-control', 'stdio');
|
|
58
|
+
}
|
|
44
59
|
if (machine) {
|
|
45
|
-
this.lastMachine = machine;
|
|
60
|
+
this.lastMachine = machine;
|
|
46
61
|
args.push('-machine', machine);
|
|
47
62
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
extensions.forEach(ext => {
|
|
51
|
-
args.push('-ext', ext);
|
|
52
|
-
});
|
|
63
|
+
if (extensions?.length > 0) {
|
|
64
|
+
extensions.forEach(ext => args.push('-ext', ext));
|
|
53
65
|
}
|
|
54
|
-
|
|
66
|
+
diag(`platform=${process.platform} IS_WINDOWS=${IS_WINDOWS}`);
|
|
67
|
+
diag(`spawn: "${executable}" ${args.join(' ')}`);
|
|
68
|
+
// Windows: stdin+stdout ignored (GUI subsystem — pipes don't work).
|
|
69
|
+
// Linux/macOS: all three streams piped.
|
|
55
70
|
this.process = spawn(executable, args, {
|
|
56
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
71
|
+
stdio: IS_WINDOWS ? ['ignore', 'ignore', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
windowsHide: false
|
|
57
73
|
});
|
|
58
|
-
if (
|
|
74
|
+
if (IS_WINDOWS && !this.process.stderr) {
|
|
75
|
+
safeResolve('Error: Failed to create stderr pipe');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!IS_WINDOWS && (!this.process.stdout || !this.process.stderr || !this.process.stdin)) {
|
|
59
79
|
safeResolve('Error: Failed to create stdio pipes');
|
|
60
80
|
return;
|
|
61
81
|
}
|
|
62
|
-
// Check if process was launched successfully
|
|
63
82
|
if (!this.process.pid || this.process.killed) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.isConnected = false;
|
|
67
|
-
safeResolve(`Error: ${stderrMessage}`);
|
|
83
|
+
this.process = null;
|
|
84
|
+
safeResolve('Error: Failed to launch openMSX process');
|
|
68
85
|
return;
|
|
69
86
|
}
|
|
70
|
-
|
|
87
|
+
diag(`process spawned PID=${this.process.pid}`);
|
|
71
88
|
this.process.on('error', (error) => {
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
diag(`process error: code=${error.code} ${error.message}`);
|
|
90
|
+
if (error.code === 'ENOENT') {
|
|
91
|
+
safeResolve(`Error: openMSX executable not found: "${executable}". ` +
|
|
92
|
+
`Set OPENMSX_EXECUTABLE to the full path. ` +
|
|
93
|
+
`On Windows: C:\\Users\\<user>\\openMSX\\openmsx.exe or ` +
|
|
94
|
+
`C:\\Program Files\\openMSX\\openmsx.exe; ` +
|
|
95
|
+
`on macOS: /Applications/openMSX.app/Contents/MacOS/openmsx; ` +
|
|
96
|
+
`on Linux: openmsx (in PATH).`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
safeResolve(`Error: ${error.message}`);
|
|
100
|
+
}
|
|
74
101
|
});
|
|
75
102
|
this.process.on('exit', (code, signal) => {
|
|
103
|
+
diag(`process exit: code=${code} signal=${signal} isConnected=${this.isConnected}`);
|
|
104
|
+
if (!resolved) {
|
|
105
|
+
safeResolve(`Error: openMSX process exited unexpectedly (code=${code}, signal=${signal}). ` +
|
|
106
|
+
`Diagnostics: ${diagLog.join(' | ')}`);
|
|
107
|
+
}
|
|
76
108
|
this.isConnected = false;
|
|
77
109
|
this.process = null;
|
|
110
|
+
this.resetIO();
|
|
78
111
|
});
|
|
79
|
-
// Wait for the opening XML tag to confirm connection
|
|
80
|
-
this.process.stdout.on('data', (data) => {
|
|
81
|
-
const output = data.toString();
|
|
82
|
-
if (output.includes('<openmsx-output>')) {
|
|
83
|
-
this.isConnected = true;
|
|
84
|
-
connectionTime = Date.now();
|
|
85
|
-
// Don't resolve immediately, wait for potential fatal errors
|
|
86
|
-
setTimeout(() => {
|
|
87
|
-
// Only resolve if no fatal error occurred during grace period
|
|
88
|
-
if (!resolved) {
|
|
89
|
-
try {
|
|
90
|
-
this.writeData('<openmsx-control>\n');
|
|
91
|
-
// Set save settings on exit off
|
|
92
|
-
this.sendCommand('set save_settings_on_exit off');
|
|
93
|
-
// Set renderer to SDL
|
|
94
|
-
this.sendCommand('set renderer SDLGL-PP');
|
|
95
|
-
// set machine on
|
|
96
|
-
this.sendCommand('set power on');
|
|
97
|
-
// start reverse replay mode
|
|
98
|
-
this.sendCommand('reverse start');
|
|
99
|
-
// Return success message
|
|
100
|
-
let result = 'Ok: openMSX emulator launched successfully';
|
|
101
|
-
if (machine) {
|
|
102
|
-
result += ` with machine "${machine}"`;
|
|
103
|
-
}
|
|
104
|
-
if (extensions && extensions.length > 0) {
|
|
105
|
-
if (machine) {
|
|
106
|
-
result += ' and';
|
|
107
|
-
}
|
|
108
|
-
result += ` with extensions: "${extensions.join('", "')}"`;
|
|
109
|
-
}
|
|
110
|
-
result += ', is powered on, and replay mode is started.';
|
|
111
|
-
safeResolve(result);
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
safeResolve(`Error: Failed to send control commands - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}, FATAL_ERROR_GRACE_PERIOD);
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
// Handle stderr - check for fatal errors during grace period
|
|
121
112
|
this.process.stderr.on('data', (data) => {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
113
|
+
const msg = data.toString().trim();
|
|
114
|
+
if (!this.isConnected)
|
|
115
|
+
diag(`stderr: ${msg.substring(0, 300)}`);
|
|
116
|
+
if (msg.includes('Fatal error:') && !this.isConnected) {
|
|
126
117
|
this.forceClose();
|
|
127
|
-
safeResolve(`Error: ${
|
|
128
|
-
return;
|
|
118
|
+
safeResolve(`Error: ${msg}`);
|
|
129
119
|
}
|
|
130
120
|
});
|
|
131
|
-
|
|
121
|
+
const onOpenMSXReady = async (sendControlTag) => {
|
|
122
|
+
diag('onOpenMSXReady: sending initial commands');
|
|
123
|
+
// On Linux/macOS: we receive <openmsx-output> first unprompted,
|
|
124
|
+
// then we must send <openmsx-control> to start the session.
|
|
125
|
+
// On Windows: we already sent <openmsx-control> to trigger <openmsx-output>,
|
|
126
|
+
// so we must NOT send it again.
|
|
127
|
+
if (sendControlTag) {
|
|
128
|
+
this.writeData('<openmsx-control>\n');
|
|
129
|
+
}
|
|
130
|
+
// await each command so replies are consumed in order and don't
|
|
131
|
+
// contaminate ioBuffer for subsequent user commands
|
|
132
|
+
await this.sendCommand('set save_settings_on_exit off');
|
|
133
|
+
await this.sendCommand('set renderer SDLGL-PP');
|
|
134
|
+
await this.sendCommand('set power on');
|
|
135
|
+
await this.sendCommand('reverse start');
|
|
136
|
+
let result = 'Ok: openMSX emulator launched successfully';
|
|
137
|
+
if (machine)
|
|
138
|
+
result += ` with machine "${machine}"`;
|
|
139
|
+
if (extensions?.length > 0) {
|
|
140
|
+
if (machine)
|
|
141
|
+
result += ' and';
|
|
142
|
+
result += ` with extensions: "${extensions.join('", "')}"`;
|
|
143
|
+
}
|
|
144
|
+
result += ', is powered on, and replay mode is started.';
|
|
145
|
+
safeResolve(result);
|
|
146
|
+
};
|
|
147
|
+
const ctx = {
|
|
148
|
+
diag, safeResolve,
|
|
149
|
+
isResolved: () => resolved,
|
|
150
|
+
onReady: onOpenMSXReady,
|
|
151
|
+
};
|
|
152
|
+
if (IS_WINDOWS) {
|
|
153
|
+
this.launchConnectWindows(ctx);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
this.launchConnectLinux(ctx);
|
|
157
|
+
}
|
|
158
|
+
// Global timeout — 20s to allow for SSPI auth + slow VMs
|
|
132
159
|
setTimeout(() => {
|
|
133
160
|
if (!this.isConnected) {
|
|
134
161
|
this.emu_close();
|
|
135
|
-
safeResolve(
|
|
162
|
+
safeResolve(`Error: Timeout waiting for openMSX to start. Diagnostics: ${diagLog.join(' | ')}`);
|
|
136
163
|
}
|
|
137
|
-
},
|
|
164
|
+
}, 20000);
|
|
138
165
|
}
|
|
139
166
|
catch (error) {
|
|
140
167
|
safeResolve(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
@@ -142,74 +169,341 @@ export class OpenMSX {
|
|
|
142
169
|
});
|
|
143
170
|
}
|
|
144
171
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
172
|
+
* Windows connection: TCP socket + SSPI authentication.
|
|
173
|
+
* Polls for the socket file, connects, authenticates, and starts the XML session.
|
|
174
|
+
*/
|
|
175
|
+
launchConnectWindows(ctx) {
|
|
176
|
+
const tmpDir = process.env.TEMP ?? process.env.TMP ??
|
|
177
|
+
path.join(os.homedir(), 'AppData', 'Local', 'Temp');
|
|
178
|
+
const socketFile = path.join(tmpDir, 'openmsx-default', `socket.${this.process.pid}`);
|
|
179
|
+
ctx.diag(`waiting for socket file: ${socketFile}`);
|
|
180
|
+
this.connectWindowsTCP(socketFile).then(async ({ socket, port }) => {
|
|
181
|
+
ctx.diag(`TCP connected on port ${port}`);
|
|
182
|
+
this.tcpSocket = socket;
|
|
183
|
+
this.ioBuffer = '';
|
|
184
|
+
socket.on('error', e => {
|
|
185
|
+
ctx.diag(`TCP socket error: ${e.message}`);
|
|
186
|
+
if (!ctx.isResolved())
|
|
187
|
+
ctx.safeResolve(`Error: TCP socket error: ${e.message}`);
|
|
188
|
+
});
|
|
189
|
+
socket.on('close', () => {
|
|
190
|
+
ctx.diag('TCP socket closed');
|
|
191
|
+
this.tcpSocket = null;
|
|
192
|
+
this.isConnected = false;
|
|
193
|
+
});
|
|
194
|
+
// Register the main data handler BEFORE performSspiAuth.
|
|
195
|
+
// This prevents any gap where data could be discarded:
|
|
196
|
+
// performSspiAuth adds its own 'data' listener that runs in
|
|
197
|
+
// parallel — both handlers receive data, but:
|
|
198
|
+
// - during SSPI: ioBuffer accumulates binary SSPI tokens
|
|
199
|
+
// (harmless, won't contain '<openmsx-output>')
|
|
200
|
+
// - after SSPI: ioBuffer is cleared, then <openmsx-control>
|
|
201
|
+
// is sent, and <openmsx-output> is detected normally.
|
|
202
|
+
socket.on('data', (data) => {
|
|
203
|
+
const chunk = data.toString();
|
|
204
|
+
// Accumulate all incoming data
|
|
205
|
+
this.ioBuffer += chunk;
|
|
206
|
+
// Signal readData() that new data arrived (notify pattern)
|
|
207
|
+
if (this.ioNotify) {
|
|
208
|
+
const notify = this.ioNotify;
|
|
209
|
+
this.ioNotify = null;
|
|
210
|
+
notify();
|
|
211
|
+
}
|
|
212
|
+
// During launch: detect <openmsx-output> in the accumulated stream
|
|
213
|
+
if (!this.isConnected && this.ioBuffer.includes('<openmsx-output>')) {
|
|
214
|
+
this.isConnected = true;
|
|
215
|
+
// Discard everything up to and including <openmsx-output>
|
|
216
|
+
this.ioBuffer = this.ioBuffer.substring(this.ioBuffer.indexOf('<openmsx-output>') + '<openmsx-output>'.length);
|
|
217
|
+
setTimeout(async () => {
|
|
218
|
+
if (!ctx.isResolved()) {
|
|
219
|
+
try {
|
|
220
|
+
await ctx.onReady(false);
|
|
221
|
+
} // Windows: don't resend <openmsx-control>
|
|
222
|
+
catch (e) {
|
|
223
|
+
ctx.safeResolve(`Error: Failed to send control commands - ${e instanceof Error ? e.message : e}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}, 300);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
// SSPI authentication (adds its own parallel 'data' listener internally)
|
|
230
|
+
try {
|
|
231
|
+
ctx.diag('starting SSPI authentication...');
|
|
232
|
+
await this.performSspiAuth(socket);
|
|
233
|
+
ctx.diag('SSPI authentication successful');
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
ctx.safeResolve(`Error: SSPI authentication failed: ${e instanceof Error ? e.message : e}. ` +
|
|
237
|
+
`Make sure 'node-expose-sspi' is installed: npm install node-expose-sspi`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Clear SSPI binary garbage from ioBuffer before XML session
|
|
241
|
+
this.ioBuffer = '';
|
|
242
|
+
// Send <openmsx-control> to initiate the XML session.
|
|
243
|
+
// On Windows/TCP, openMSX sends <openmsx-output> in response.
|
|
244
|
+
ctx.diag('sending <openmsx-control> to start XML session');
|
|
245
|
+
socket.write('<openmsx-control>\n');
|
|
246
|
+
}).catch(err => {
|
|
247
|
+
ctx.diag(`connectWindowsTCP failed: ${err.message}`);
|
|
248
|
+
ctx.safeResolve(`Error: ${err.message}`);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Linux/macOS connection: stdio pipes.
|
|
253
|
+
* Registers stdout handler for ioBuffer accumulation and <openmsx-output> detection.
|
|
254
|
+
*/
|
|
255
|
+
launchConnectLinux(ctx) {
|
|
256
|
+
const FATAL_ERROR_GRACE_PERIOD = 500;
|
|
257
|
+
let connectionTime = null;
|
|
258
|
+
this.process.stdout.on('data', (data) => {
|
|
259
|
+
const output = data.toString();
|
|
260
|
+
if (!this.isConnected) {
|
|
261
|
+
if (output.includes('<openmsx-output>')) {
|
|
262
|
+
ctx.diag('<openmsx-output> detected on stdout');
|
|
263
|
+
this.isConnected = true;
|
|
264
|
+
this.ioBuffer = '';
|
|
265
|
+
connectionTime = Date.now();
|
|
266
|
+
setTimeout(async () => {
|
|
267
|
+
if (!ctx.isResolved()) {
|
|
268
|
+
try {
|
|
269
|
+
await ctx.onReady(true);
|
|
270
|
+
} // Linux/macOS: must send <openmsx-control>
|
|
271
|
+
catch (e) {
|
|
272
|
+
ctx.safeResolve(`Error: Failed to send control commands - ${e instanceof Error ? e.message : e}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}, FATAL_ERROR_GRACE_PERIOD);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
// Accumulate for readData() — persistent shared ioBuffer
|
|
280
|
+
this.ioBuffer += output;
|
|
281
|
+
if (this.ioNotify) {
|
|
282
|
+
const notify = this.ioNotify;
|
|
283
|
+
this.ioNotify = null;
|
|
284
|
+
notify();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
this.process.stderr.on('data', (data) => {
|
|
289
|
+
const msg = data.toString().trim();
|
|
290
|
+
const isInGracePeriod = connectionTime && (Date.now() - connectionTime) < FATAL_ERROR_GRACE_PERIOD;
|
|
291
|
+
if (msg.includes('Fatal error:') && (!this.isConnected || isInGracePeriod)) {
|
|
292
|
+
this.forceClose();
|
|
293
|
+
ctx.safeResolve(`Error: ${msg}`);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* SSPI (Negotiate/NTLM) authentication handshake — Windows only.
|
|
299
|
+
* Required since openMSX 0.7.1 for TCP socket connections.
|
|
300
|
+
* Uses `node-expose-sspi` v0.1.x optional npm package.
|
|
301
|
+
*
|
|
302
|
+
* Reference C++ implementation: openMSX debugger SspiNegotiateClient.cpp
|
|
303
|
+
* Protocol: loop until SEC_E_OK — each round:
|
|
304
|
+
* client → [4-byte BE length][SSPI token]
|
|
305
|
+
* server → [4-byte BE length][SSPI response] (if SEC_I_CONTINUE_NEEDED)
|
|
306
|
+
* After SEC_E_OK: no server read — proceed directly with XML protocol.
|
|
147
307
|
*/
|
|
308
|
+
async performSspiAuth(socket) {
|
|
309
|
+
let nes;
|
|
310
|
+
try {
|
|
311
|
+
const { createRequire } = await import('module');
|
|
312
|
+
const req = createRequire(import.meta.url);
|
|
313
|
+
nes = req('node-expose-sspi');
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
throw new Error(`node-expose-sspi not available (${e instanceof Error ? e.message : e}). ` +
|
|
317
|
+
`Install with: npm install node-expose-sspi`);
|
|
318
|
+
}
|
|
319
|
+
// Accumulate TCP data for length-prefixed reads during SSPI phase.
|
|
320
|
+
// Pattern: onSspiData appends to buffer and NOTIFIES (does not clear).
|
|
321
|
+
// readLengthPrefixed checks the buffer size after each notification.
|
|
322
|
+
let sspiBuffer = Buffer.alloc(0);
|
|
323
|
+
let sspiNotify = null;
|
|
324
|
+
const onSspiData = (chunk) => {
|
|
325
|
+
sspiBuffer = Buffer.concat([sspiBuffer, chunk]);
|
|
326
|
+
if (sspiNotify) {
|
|
327
|
+
const notify = sspiNotify;
|
|
328
|
+
sspiNotify = null;
|
|
329
|
+
notify(); // just signal — buffer stays intact
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
socket.on('data', onSspiData);
|
|
333
|
+
// Wait until new data arrives (doesn't clear the buffer)
|
|
334
|
+
const waitMore = () => new Promise(resolve => {
|
|
335
|
+
sspiNotify = resolve;
|
|
336
|
+
});
|
|
337
|
+
const readLengthPrefixed = async () => {
|
|
338
|
+
// Wait until we have at least the 4-byte length prefix
|
|
339
|
+
while (sspiBuffer.length < 4)
|
|
340
|
+
await waitMore();
|
|
341
|
+
const len = sspiBuffer.readUInt32BE(0);
|
|
342
|
+
const total = 4 + len;
|
|
343
|
+
// Wait until the full payload has arrived
|
|
344
|
+
while (sspiBuffer.length < total)
|
|
345
|
+
await waitMore();
|
|
346
|
+
const result = sspiBuffer.slice(4, total);
|
|
347
|
+
sspiBuffer = sspiBuffer.slice(total); // consume only what we read
|
|
348
|
+
return result;
|
|
349
|
+
};
|
|
350
|
+
const sendToken = (token) => {
|
|
351
|
+
const buf = Buffer.from(token);
|
|
352
|
+
const lenBuf = Buffer.alloc(4);
|
|
353
|
+
lenBuf.writeUInt32BE(buf.length, 0);
|
|
354
|
+
socket.write(lenBuf);
|
|
355
|
+
socket.write(buf);
|
|
356
|
+
};
|
|
357
|
+
try {
|
|
358
|
+
// node-expose-sspi v0.1.x API:
|
|
359
|
+
// AcquireCredentialsHandle → CredentialWithExpiry { credential, tsExpiry }
|
|
360
|
+
// InitializeSecurityContextInput.credential = CredHandle (the .credential property)
|
|
361
|
+
// InitializeSecurityContextInput.SecBufferDesc = server's token (NOT serverSecurityContext)
|
|
362
|
+
// InitializeSecurityContextInput.contextReq = string[] of ISC_REQ_* flags
|
|
363
|
+
// Flags from openMSX C++ ref: ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_CONNECTION | ISC_REQ_STREAM
|
|
364
|
+
const credWithExpiry = nes.sspi.AcquireCredentialsHandle({
|
|
365
|
+
packageName: 'Negotiate',
|
|
366
|
+
credentialUse: 'SECPKG_CRED_OUTBOUND',
|
|
367
|
+
});
|
|
368
|
+
const credential = credWithExpiry.credential;
|
|
369
|
+
const packageInfo = nes.sspi.QuerySecurityPackageInfo('Negotiate');
|
|
370
|
+
const ISC_FLAGS = ['ISC_REQ_ALLOCATE_MEMORY', 'ISC_REQ_CONNECTION', 'ISC_REQ_STREAM'];
|
|
371
|
+
let contextHandle = undefined;
|
|
372
|
+
let serverSecBufDesc = undefined;
|
|
373
|
+
// Loop until SEC_E_OK (mirrors C++ reference implementation)
|
|
374
|
+
while (true) {
|
|
375
|
+
const ctxInput = {
|
|
376
|
+
credential,
|
|
377
|
+
targetName: '',
|
|
378
|
+
cbMaxToken: packageInfo.cbMaxToken,
|
|
379
|
+
contextReq: ISC_FLAGS,
|
|
380
|
+
targetDataRep: 'SECURITY_NETWORK_DREP',
|
|
381
|
+
};
|
|
382
|
+
if (contextHandle !== undefined)
|
|
383
|
+
ctxInput.contextHandle = contextHandle;
|
|
384
|
+
if (serverSecBufDesc !== undefined)
|
|
385
|
+
ctxInput.SecBufferDesc = serverSecBufDesc;
|
|
386
|
+
const clientCtx = nes.sspi.InitializeSecurityContext(ctxInput);
|
|
387
|
+
contextHandle = clientCtx.contextHandle;
|
|
388
|
+
// Send our token to the server (if non-empty)
|
|
389
|
+
const tokenBuf = clientCtx.SecBufferDesc?.buffers?.[0];
|
|
390
|
+
if (tokenBuf && tokenBuf.byteLength > 0) {
|
|
391
|
+
sendToken(tokenBuf);
|
|
392
|
+
}
|
|
393
|
+
if (clientCtx.SECURITY_STATUS === 'SEC_E_OK') {
|
|
394
|
+
// Auth complete — no final read from server, proceed to XML
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
if (clientCtx.SECURITY_STATUS !== 'SEC_I_CONTINUE_NEEDED') {
|
|
398
|
+
throw new Error(`SSPI error: ${clientCtx.SECURITY_STATUS}`);
|
|
399
|
+
}
|
|
400
|
+
// Read server's response token
|
|
401
|
+
const response = await readLengthPrefixed();
|
|
402
|
+
const responseAB = response.buffer.slice(response.byteOffset, response.byteOffset + response.byteLength);
|
|
403
|
+
serverSecBufDesc = { ulVersion: 0, buffers: [responseAB] };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
// Remove SSPI data handler — main handler will be added after this returns
|
|
408
|
+
socket.removeListener('data', onSspiData);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Poll for the TCP socket file, read the port, connect.
|
|
413
|
+
*/
|
|
414
|
+
connectWindowsTCP(socketFile) {
|
|
415
|
+
return new Promise((resolve, reject) => {
|
|
416
|
+
const maxWaitMs = 8000;
|
|
417
|
+
const pollMs = 200;
|
|
418
|
+
let elapsed = 0;
|
|
419
|
+
const poll = () => {
|
|
420
|
+
if (fsSync.existsSync(socketFile)) {
|
|
421
|
+
let port;
|
|
422
|
+
try {
|
|
423
|
+
port = parseInt(fsSync.readFileSync(socketFile, 'utf8').trim(), 10);
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
return reject(new Error(`Cannot read socket file: ${e}`));
|
|
427
|
+
}
|
|
428
|
+
if (!port || isNaN(port))
|
|
429
|
+
return reject(new Error(`Invalid port in socket file`));
|
|
430
|
+
const sock = net.createConnection(port, '127.0.0.1');
|
|
431
|
+
sock.once('connect', () => resolve({ socket: sock, port }));
|
|
432
|
+
sock.once('error', err => { sock.destroy(); reject(new Error(`TCP connect to ${port} failed: ${err.message}`)); });
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
elapsed += pollMs;
|
|
436
|
+
if (elapsed >= maxWaitMs) {
|
|
437
|
+
reject(new Error(`openMSX socket file not found after ${maxWaitMs}ms: ${socketFile}`));
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
setTimeout(poll, pollMs);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
poll();
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
resetIO() {
|
|
448
|
+
if (this.tcpSocket) {
|
|
449
|
+
try {
|
|
450
|
+
this.tcpSocket.destroy();
|
|
451
|
+
}
|
|
452
|
+
catch (_) { /* ignore */ }
|
|
453
|
+
this.tcpSocket = null;
|
|
454
|
+
}
|
|
455
|
+
this.ioBuffer = '';
|
|
456
|
+
this.ioNotify = null;
|
|
457
|
+
}
|
|
148
458
|
async emu_close() {
|
|
149
459
|
return new Promise((resolve) => {
|
|
460
|
+
let resolved = false;
|
|
461
|
+
const safeResolve = (message) => {
|
|
462
|
+
if (!resolved) {
|
|
463
|
+
resolved = true;
|
|
464
|
+
resolve(message);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
150
467
|
if (!this.process) {
|
|
151
|
-
|
|
468
|
+
safeResolve("Error: No emulator process running");
|
|
152
469
|
return;
|
|
153
470
|
}
|
|
154
471
|
this.process.on('exit', () => {
|
|
155
|
-
this.lastMachine = null;
|
|
472
|
+
this.lastMachine = null;
|
|
156
473
|
this.isConnected = false;
|
|
157
474
|
this.process = null;
|
|
158
|
-
|
|
475
|
+
this.resetIO();
|
|
476
|
+
safeResolve("Ok: Emulator process closed successfully");
|
|
159
477
|
});
|
|
160
|
-
this.process.on('error', (error) => {
|
|
161
|
-
resolve(`Error: error closing emulator: ${error.message}`);
|
|
162
|
-
});
|
|
163
|
-
// Try graceful shutdown first
|
|
478
|
+
this.process.on('error', (error) => safeResolve(`Error: error closing emulator: ${error.message}`));
|
|
164
479
|
if (this.isConnected) {
|
|
165
|
-
|
|
166
|
-
this.sendCommand('exit');
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
// If writing fails, force kill.
|
|
170
|
-
// Use no-argument kill() for cross-platform safety:
|
|
171
|
-
// on POSIX it sends SIGTERM; on Windows it calls TerminateProcess().
|
|
480
|
+
this.sendCommand('exit').catch(() => {
|
|
172
481
|
try {
|
|
173
|
-
this.process
|
|
482
|
+
this.process?.kill();
|
|
174
483
|
}
|
|
175
484
|
catch (_) { /* ignore */ }
|
|
176
|
-
}
|
|
485
|
+
});
|
|
177
486
|
}
|
|
178
487
|
else {
|
|
179
488
|
this.forceClose();
|
|
180
|
-
|
|
489
|
+
safeResolve("Error: Emulator process had to be force killed");
|
|
181
490
|
}
|
|
182
|
-
|
|
183
|
-
setTimeout(() => {
|
|
184
|
-
this.forceClose();
|
|
185
|
-
resolve("Error: Timeout. Emulator process had to be force killed");
|
|
186
|
-
}, 1000);
|
|
491
|
+
setTimeout(() => { this.forceClose(); safeResolve("Error: Timeout. Emulator process had to be force killed"); }, 1000);
|
|
187
492
|
});
|
|
188
493
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Get the status of the openMSX emulator using machine_info command
|
|
191
|
-
* @returns Promise<string> - JSON string with machine information or error message
|
|
192
|
-
*/
|
|
193
494
|
async emu_status() {
|
|
194
495
|
try {
|
|
195
496
|
const response = await this.sendCommand('machine_info');
|
|
196
|
-
if (response.startsWith('Error:'))
|
|
497
|
+
if (response.startsWith('Error:'))
|
|
197
498
|
return response;
|
|
198
|
-
}
|
|
199
|
-
// Parse machine_info output into key-value pairs
|
|
200
499
|
const skipInfo = ['issubslotted', 'input_port', 'slot', 'isexternalslot', 'output_port'];
|
|
201
500
|
const parameters = response.trim().split(' ');
|
|
202
501
|
const machineInfo = {};
|
|
203
502
|
for (const param of parameters) {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
if (skipInfo.includes(trimmedLine)) {
|
|
503
|
+
const trimmed = param.trim();
|
|
504
|
+
if (skipInfo.includes(trimmed) || !trimmed)
|
|
207
505
|
continue;
|
|
208
|
-
}
|
|
209
|
-
if (trimmedLine) {
|
|
210
|
-
const value = await this.sendCommand(`machine_info ${trimmedLine}`);
|
|
211
|
-
machineInfo[trimmedLine] = value.trim();
|
|
212
|
-
}
|
|
506
|
+
machineInfo[trimmed] = (await this.sendCommand(`machine_info ${trimmed}`)).trim();
|
|
213
507
|
}
|
|
214
508
|
return JSON.stringify(machineInfo, null, 2);
|
|
215
509
|
}
|
|
@@ -222,151 +516,128 @@ export class OpenMSX {
|
|
|
222
516
|
const response = await this.sendCommand('slotselect');
|
|
223
517
|
return response.includes('0000: slot 0') && response.includes('4000: slot 0');
|
|
224
518
|
}
|
|
225
|
-
catch (
|
|
519
|
+
catch (_) {
|
|
226
520
|
return false;
|
|
227
521
|
}
|
|
228
522
|
}
|
|
229
|
-
/**
|
|
230
|
-
* Get the list of machines available in the openMSX emulator
|
|
231
|
-
* @returns Promise<object> - object with machine names and descriptions or error message
|
|
232
|
-
*/
|
|
233
523
|
async getMachineList(machinesDirectory) {
|
|
234
|
-
|
|
235
|
-
let machines = [];
|
|
236
|
-
let machinesList = "Error: No machines found.";
|
|
237
|
-
try {
|
|
238
|
-
const allFiles = await fs.readdir(machinesDirectory);
|
|
239
|
-
machines = await Promise.all(allFiles
|
|
240
|
-
.filter((file) => file.endsWith('.xml'))
|
|
241
|
-
.map(async (file) => {
|
|
242
|
-
return {
|
|
243
|
-
name: file.replace('.xml', ''),
|
|
244
|
-
description: await extractDescriptionFromXML(path.join(machinesDirectory, file))
|
|
245
|
-
};
|
|
246
|
-
}));
|
|
247
|
-
if (machines.length !== 0) {
|
|
248
|
-
machinesList = JSON.stringify(machines, null, 2);
|
|
249
|
-
}
|
|
250
|
-
return machinesList;
|
|
251
|
-
}
|
|
252
|
-
catch (error) {
|
|
253
|
-
return `Error: error reading machines directory - ${error instanceof Error ? error.message : error}`;
|
|
254
|
-
}
|
|
524
|
+
return this.getXMLList(machinesDirectory, 'machines');
|
|
255
525
|
}
|
|
256
|
-
/**
|
|
257
|
-
* Get the list of extensions available in the openMSX emulator
|
|
258
|
-
* @returns Promise<object> - object with extension names and descriptions or error message
|
|
259
|
-
*/
|
|
260
526
|
async getExtensionList(extensionDirectory) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
527
|
+
return this.getXMLList(extensionDirectory, 'extensions');
|
|
528
|
+
}
|
|
529
|
+
async getXMLList(directory, entityName) {
|
|
264
530
|
try {
|
|
265
|
-
const allFiles = await fs.readdir(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
description: await extractDescriptionFromXML(path.join(extensionDirectory, file))
|
|
272
|
-
};
|
|
273
|
-
}));
|
|
274
|
-
if (extensions.length !== 0) {
|
|
275
|
-
extensionsList = JSON.stringify(extensions, null, 2);
|
|
276
|
-
}
|
|
277
|
-
return extensionsList;
|
|
531
|
+
const allFiles = await fs.readdir(directory);
|
|
532
|
+
const items = await Promise.all(allFiles.filter(f => f.endsWith('.xml')).map(async (f) => ({
|
|
533
|
+
name: f.replace('.xml', ''),
|
|
534
|
+
description: await extractDescriptionFromXML(path.join(directory, f))
|
|
535
|
+
})));
|
|
536
|
+
return items.length ? JSON.stringify(items, null, 2) : `Error: No ${entityName} found.`;
|
|
278
537
|
}
|
|
279
538
|
catch (error) {
|
|
280
|
-
return `Error: error reading
|
|
539
|
+
return `Error: error reading ${entityName} directory - ${error instanceof Error ? error.message : error}`;
|
|
281
540
|
}
|
|
282
541
|
}
|
|
283
|
-
;
|
|
284
542
|
/**
|
|
285
|
-
* Send a command to
|
|
286
|
-
*
|
|
287
|
-
*
|
|
543
|
+
* Send a TCL command to openMSX and return the response.
|
|
544
|
+
*
|
|
545
|
+
* Internally serialized via a promise queue so concurrent callers (with or
|
|
546
|
+
* without `await`) never overlap — each command waits for the previous one
|
|
547
|
+
* to complete before writing to the channel and reading the reply.
|
|
548
|
+
* This is safe on all platforms (Linux/macOS stdio and Windows TCP).
|
|
288
549
|
*/
|
|
289
550
|
async sendCommand(command) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const outputContent = decodeHtmlEntities(replyMatch[2].trim());
|
|
299
|
-
if (replyMatch[1] === 'ok') {
|
|
300
|
-
return outputContent;
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
return `Error: ${outputContent}`;
|
|
551
|
+
const execute = async () => {
|
|
552
|
+
try {
|
|
553
|
+
this.writeData(`<command>${encodeHtmlEntities(command)}</command>\n`);
|
|
554
|
+
const output = (await this.readData()).trim();
|
|
555
|
+
const replyMatch = output.match(/<reply result="(ok|nok)"[^>]*>(.*?)<\/reply>/s);
|
|
556
|
+
if (replyMatch) {
|
|
557
|
+
const content = decodeHtmlEntities(replyMatch[2].trim());
|
|
558
|
+
return replyMatch[1] === 'ok' ? content : `Error: ${content}`;
|
|
304
559
|
}
|
|
560
|
+
return decodeHtmlEntities(output);
|
|
305
561
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
562
|
+
catch (error) {
|
|
563
|
+
return `Error: ${error instanceof Error ? error.message : error}`;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
// Chain onto the queue: wait for the previous command to finish, then run
|
|
567
|
+
const result = this.commandQueue.then(execute, execute);
|
|
568
|
+
// Update the queue tail — swallow errors so the chain never breaks
|
|
569
|
+
this.commandQueue = result.then(() => '', () => '');
|
|
570
|
+
return result;
|
|
312
571
|
}
|
|
313
|
-
/**
|
|
314
|
-
* Write data to the openMSX process stdin
|
|
315
|
-
* @param data - XML command or data to send
|
|
316
|
-
*/
|
|
317
572
|
writeData(data) {
|
|
318
|
-
if (!this.process || !this.
|
|
573
|
+
if (!this.process || !this.isConnected)
|
|
319
574
|
throw new Error('openMSX process not running or not connected');
|
|
575
|
+
if (IS_WINDOWS) {
|
|
576
|
+
if (!this.tcpSocket || this.tcpSocket.destroyed)
|
|
577
|
+
throw new Error('Windows TCP socket not open');
|
|
578
|
+
this.tcpSocket.write(data);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
if (!this.process.stdin)
|
|
582
|
+
throw new Error('openMSX stdin not available');
|
|
583
|
+
this.process.stdin.write(data);
|
|
320
584
|
}
|
|
321
|
-
this.process.stdin.write(data);
|
|
322
585
|
}
|
|
323
|
-
/**
|
|
324
|
-
* Read data from openMSX process stdout
|
|
325
|
-
* @returns Promise<string> - The data received from stdout
|
|
326
|
-
*/
|
|
327
586
|
readData() {
|
|
328
587
|
return new Promise((resolve, reject) => {
|
|
329
|
-
if (!this.
|
|
588
|
+
if (!this.isConnected) {
|
|
330
589
|
reject(new Error('openMSX process not running or not connected'));
|
|
331
590
|
return;
|
|
332
591
|
}
|
|
333
|
-
|
|
334
|
-
this.
|
|
335
|
-
|
|
592
|
+
if (IS_WINDOWS) {
|
|
593
|
+
if (!this.tcpSocket || this.tcpSocket.destroyed) {
|
|
594
|
+
reject(new Error('Windows TCP socket not open'));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
if (!this.process?.stdout) {
|
|
600
|
+
reject(new Error('openMSX stdout not available'));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// Unified for both platforms: accumulate in ioBuffer until a complete
|
|
605
|
+
// <reply>…</reply> block, then extract and return it.
|
|
606
|
+
const RESPONSE_TIMEOUT = 10000;
|
|
607
|
+
const timer = setTimeout(() => {
|
|
608
|
+
this.ioNotify = null;
|
|
609
|
+
this.ioBuffer = '';
|
|
610
|
+
reject(new Error('Timeout waiting for openMSX response'));
|
|
611
|
+
}, RESPONSE_TIMEOUT);
|
|
612
|
+
const tryExtractReply = () => {
|
|
613
|
+
const end = this.ioBuffer.indexOf('</reply>');
|
|
614
|
+
if (end !== -1) {
|
|
615
|
+
clearTimeout(timer);
|
|
616
|
+
const full = this.ioBuffer.substring(0, end + '</reply>'.length);
|
|
617
|
+
this.ioBuffer = this.ioBuffer.substring(end + '</reply>'.length);
|
|
618
|
+
resolve(full);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
this.ioNotify = tryExtractReply;
|
|
622
|
+
}
|
|
336
623
|
};
|
|
337
|
-
|
|
624
|
+
tryExtractReply();
|
|
338
625
|
});
|
|
339
626
|
}
|
|
340
|
-
/**
|
|
341
|
-
* Destructor - Clean up resources and close emulator if running
|
|
342
|
-
* This method should be called when the instance is no longer needed
|
|
343
|
-
*/
|
|
344
627
|
async destroy() {
|
|
345
|
-
if (this.process && !this.process.killed)
|
|
628
|
+
if (this.process && !this.process.killed)
|
|
346
629
|
await this.emu_close();
|
|
347
|
-
}
|
|
348
630
|
}
|
|
349
|
-
/**
|
|
350
|
-
* Force close the emulator immediately (synchronous)
|
|
351
|
-
* Used for emergency shutdown when async methods may not work
|
|
352
|
-
*/
|
|
353
631
|
forceClose() {
|
|
354
632
|
if (this.process && !this.process.killed) {
|
|
355
633
|
try {
|
|
356
|
-
// 'SIGKILL' is accepted on Windows too (maps to TerminateProcess).
|
|
357
|
-
// No-argument kill() is also acceptable here, but SIGKILL makes
|
|
358
|
-
// the intent explicit: we want unconditional termination.
|
|
359
634
|
this.process.kill('SIGKILL');
|
|
360
635
|
}
|
|
361
|
-
catch (
|
|
362
|
-
// Ignore errors during force close
|
|
363
|
-
}
|
|
636
|
+
catch (_) { /* ignore */ }
|
|
364
637
|
this.process = null;
|
|
365
638
|
this.isConnected = false;
|
|
366
639
|
}
|
|
640
|
+
this.resetIO();
|
|
367
641
|
}
|
|
368
642
|
}
|
|
369
|
-
/**
|
|
370
|
-
* Global instance of OpenMSX for emulator control
|
|
371
|
-
*/
|
|
372
643
|
export const openMSXInstance = new OpenMSX();
|
package/dist/server.js
CHANGED
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
20
20
|
import { createRequire } from 'module';
|
|
21
21
|
import { openMSXInstance } from "./openmsx.js";
|
|
22
22
|
import { VectorDB } from "./vectordb.js";
|
|
23
|
-
import { detectOpenMSXShareDir } from "./utils.js";
|
|
23
|
+
import { detectOpenMSXExecutable, detectOpenMSXShareDir } from "./utils.js";
|
|
24
24
|
import { registerTools } from "./server_tools.js";
|
|
25
25
|
import { registerResources } from "./server_resources.js";
|
|
26
26
|
import { registerPrompts } from "./server_prompts.js";
|
|
@@ -32,9 +32,7 @@ const resourcesDir = path.join(__dirname, "../resources");
|
|
|
32
32
|
const vectorDbDir = path.join(__dirname, "../vector-db");
|
|
33
33
|
export const emuDirectories = {
|
|
34
34
|
OPENMSX_SHARE_DIR: '',
|
|
35
|
-
|
|
36
|
-
// Using the platform-aware default reduces friction for Windows users who have openMSX in PATH.
|
|
37
|
-
OPENMSX_EXECUTABLE: process.platform === 'win32' ? 'openmsx.exe' : 'openmsx',
|
|
35
|
+
OPENMSX_EXECUTABLE: detectOpenMSXExecutable(),
|
|
38
36
|
OPENMSX_REPLAYS_DIR: '',
|
|
39
37
|
OPENMSX_SCREENSHOT_DIR: '',
|
|
40
38
|
OPENMSX_SCREENDUMP_DIR: '',
|
|
@@ -232,6 +230,15 @@ async function main() {
|
|
|
232
230
|
}
|
|
233
231
|
emuDirectories.MACHINES_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'machines');
|
|
234
232
|
emuDirectories.EXTENSIONS_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'extensions');
|
|
233
|
+
if (!emuDirectories.OPENMSX_SCREENSHOT_DIR) {
|
|
234
|
+
emuDirectories.OPENMSX_SCREENSHOT_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'screenshots');
|
|
235
|
+
}
|
|
236
|
+
if (!emuDirectories.OPENMSX_SCREENDUMP_DIR) {
|
|
237
|
+
emuDirectories.OPENMSX_SCREENDUMP_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'screenshots');
|
|
238
|
+
}
|
|
239
|
+
if (!emuDirectories.OPENMSX_REPLAYS_DIR) {
|
|
240
|
+
emuDirectories.OPENMSX_REPLAYS_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'replays');
|
|
241
|
+
}
|
|
235
242
|
VectorDB.setIndexDirectory(vectorDbDir);
|
|
236
243
|
// Detect transport type from environment or command line
|
|
237
244
|
const transportType = process.env.MCP_TRANSPORT || process.argv[2] || 'stdio';
|
package/dist/server_tools.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "fs/promises";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { openMSXInstance } from "./openmsx.js";
|
|
5
5
|
import { VectorDB } from "./vectordb.js";
|
|
6
|
-
import { encodeTypeText, buildKeyComboCommand, isErrorResponse, getResponseContent, parseCpuRegs, is16bitRegister, parseVdpRegs, parsePalette, parseBreakpoints, parseReplayStatus, sleepWithAbort } from "./utils.js";
|
|
6
|
+
import { encodeTypeText, buildKeyComboCommand, isErrorResponse, getResponseContent, parseCpuRegs, is16bitRegister, parseVdpRegs, parsePalette, parseBreakpoints, parseReplayStatus, sleepWithAbort, ensureDirectoryExists, tclPath } from "./utils.js";
|
|
7
7
|
import { getRegisteredResourcesList } from "./server_resources.js";
|
|
8
8
|
import { resolveLaunchParams } from "./server_elicitations.js";
|
|
9
9
|
// ============================================================================
|
|
@@ -1246,13 +1246,19 @@ export async function registerTools(server, emuDirectories) {
|
|
|
1246
1246
|
tclCommand = `reverse_frame ${frames}`;
|
|
1247
1247
|
break;
|
|
1248
1248
|
case "saveReplay":
|
|
1249
|
-
if (filename)
|
|
1250
|
-
filename
|
|
1249
|
+
if (filename) {
|
|
1250
|
+
if (!filename.endsWith('.omr'))
|
|
1251
|
+
filename += '.omr';
|
|
1252
|
+
filename = tclPath(path.join(emuDirectories.OPENMSX_REPLAYS_DIR, filename));
|
|
1253
|
+
}
|
|
1251
1254
|
tclCommand = `reverse savereplay ${filename || ''}`;
|
|
1252
1255
|
break;
|
|
1253
1256
|
case "loadReplay":
|
|
1254
|
-
if (filename)
|
|
1255
|
-
filename
|
|
1257
|
+
if (filename) {
|
|
1258
|
+
if (!filename.endsWith('.omr'))
|
|
1259
|
+
filename += '.omr';
|
|
1260
|
+
filename = tclPath(path.join(emuDirectories.OPENMSX_REPLAYS_DIR, filename));
|
|
1261
|
+
}
|
|
1256
1262
|
tclCommand = `reverse loadreplay ${filename}`;
|
|
1257
1263
|
break;
|
|
1258
1264
|
default:
|
|
@@ -1394,20 +1400,46 @@ export async function registerTools(server, emuDirectories) {
|
|
|
1394
1400
|
},
|
|
1395
1401
|
// Handler for the tool (function to be executed when the tool is called)
|
|
1396
1402
|
async ({ command }) => {
|
|
1397
|
-
|
|
1403
|
+
// Ensure the screenshot directory exists before saving
|
|
1404
|
+
const screenshotDirError = await ensureDirectoryExists(emuDirectories.OPENMSX_SCREENSHOT_DIR);
|
|
1405
|
+
if (screenshotDirError) {
|
|
1406
|
+
return getResponseContent([`Error: ${screenshotDirError}`], true);
|
|
1407
|
+
}
|
|
1408
|
+
// Use a unique prefix so we can locate the file even if openMSX
|
|
1409
|
+
// doesn't return the filename (some versions return empty on success).
|
|
1410
|
+
const uniqueId = `mcp_${Date.now()}_`;
|
|
1411
|
+
const screenshotPrefix = tclPath(path.join(emuDirectories.OPENMSX_SCREENSHOT_DIR, uniqueId));
|
|
1412
|
+
const openmsxCommand = `screenshot -raw -doublesize -prefix "${screenshotPrefix}"`;
|
|
1398
1413
|
const response = await openMSXInstance.sendCommand(openmsxCommand);
|
|
1414
|
+
if (isErrorResponse(response)) {
|
|
1415
|
+
return getResponseContent([response], true);
|
|
1416
|
+
}
|
|
1417
|
+
// Resolve the screenshot file path: use the response if it contains
|
|
1418
|
+
// a valid path, otherwise scan the directory for the generated file.
|
|
1419
|
+
let screenshotPath = response;
|
|
1420
|
+
if (!screenshotPath || !screenshotPath.endsWith('.png')) {
|
|
1421
|
+
try {
|
|
1422
|
+
const files = await fs.readdir(emuDirectories.OPENMSX_SCREENSHOT_DIR);
|
|
1423
|
+
const match = files.find(f => f.startsWith(uniqueId) && f.endsWith('.png'));
|
|
1424
|
+
if (match) {
|
|
1425
|
+
screenshotPath = path.join(emuDirectories.OPENMSX_SCREENSHOT_DIR, match);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
catch (_) { /* directory read failed, screenshotPath stays empty */ }
|
|
1429
|
+
}
|
|
1430
|
+
if (!screenshotPath || !screenshotPath.endsWith('.png')) {
|
|
1431
|
+
return getResponseContent([
|
|
1432
|
+
`Error: Screenshot command succeeded but no file was found (prefix: "${uniqueId}")`,
|
|
1433
|
+
], true);
|
|
1434
|
+
}
|
|
1399
1435
|
switch (command) {
|
|
1400
1436
|
case "as_image":
|
|
1401
1437
|
try {
|
|
1402
|
-
// Check if the response is a file path
|
|
1403
|
-
if (!response || !response.startsWith(emuDirectories.OPENMSX_SCREENSHOT_DIR) || !response.endsWith('.png')) {
|
|
1404
|
-
throw new Error(`Invalid screenshot "${response}"`);
|
|
1405
|
-
}
|
|
1406
1438
|
// Read the screenshot file
|
|
1407
|
-
const imageBuffer = await fs.readFile(
|
|
1439
|
+
const imageBuffer = await fs.readFile(screenshotPath);
|
|
1408
1440
|
const base64image = imageBuffer.toString('base64');
|
|
1409
1441
|
// Remove the file after reading it
|
|
1410
|
-
await fs.unlink(
|
|
1442
|
+
await fs.unlink(screenshotPath);
|
|
1411
1443
|
// Return the image in the response
|
|
1412
1444
|
return {
|
|
1413
1445
|
content: [{
|
|
@@ -1422,13 +1454,13 @@ export async function registerTools(server, emuDirectories) {
|
|
|
1422
1454
|
}
|
|
1423
1455
|
catch (error) {
|
|
1424
1456
|
return getResponseContent([
|
|
1425
|
-
'Error
|
|
1457
|
+
'Error reading screenshot file: ' + screenshotPath,
|
|
1426
1458
|
error instanceof Error ? error.message : String(error),
|
|
1427
1459
|
], true);
|
|
1428
1460
|
}
|
|
1429
1461
|
case "to_file":
|
|
1430
1462
|
return getResponseContent([
|
|
1431
|
-
|
|
1463
|
+
'Screenshot taken in file: ' + screenshotPath
|
|
1432
1464
|
]);
|
|
1433
1465
|
}
|
|
1434
1466
|
return getResponseContent([
|
|
@@ -1461,7 +1493,12 @@ The parameter scrbasename is the name of the filename (without path) to save the
|
|
|
1461
1493
|
},
|
|
1462
1494
|
// Handler for the tool (function to be executed when the tool is called)
|
|
1463
1495
|
async ({ scrbasename }) => {
|
|
1464
|
-
|
|
1496
|
+
// Ensure the screendump directory exists before saving
|
|
1497
|
+
const screendumpDirError = await ensureDirectoryExists(emuDirectories.OPENMSX_SCREENDUMP_DIR);
|
|
1498
|
+
if (screendumpDirError) {
|
|
1499
|
+
return getResponseContent([`Error: ${screendumpDirError}`], true);
|
|
1500
|
+
}
|
|
1501
|
+
const openmsxCommand = `save_msx_screen "${tclPath(path.join(emuDirectories.OPENMSX_SCREENDUMP_DIR, scrbasename))}"`;
|
|
1465
1502
|
const response = await openMSXInstance.sendCommand(openmsxCommand);
|
|
1466
1503
|
return getResponseContent([
|
|
1467
1504
|
isErrorResponse(response) ? 'Fail:' : 'Screendump file saved as:',
|
package/dist/utils.js
CHANGED
|
@@ -12,6 +12,32 @@ import { PACKAGE_VERSION } from "./server.js";
|
|
|
12
12
|
import sanitizeHtml from 'sanitize-html';
|
|
13
13
|
import { existsSync } from "fs";
|
|
14
14
|
import os from "os";
|
|
15
|
+
/**
|
|
16
|
+
* Detect the openMSX executable path for the current platform.
|
|
17
|
+
*
|
|
18
|
+
* - Linux: 'openmsx' (expected in PATH after package install)
|
|
19
|
+
* - Windows: 'openmsx.exe' (Node spawn() needs the .exe extension on Windows)
|
|
20
|
+
* - macOS: probes the standard .app bundle path first; falls back to 'openmsx'
|
|
21
|
+
* in case the user has it in PATH (e.g. via Homebrew or manual install).
|
|
22
|
+
*
|
|
23
|
+
* The standard macOS bundle path is /Applications/openMSX.app/Contents/MacOS/openmsx.
|
|
24
|
+
* This is the path documented by the openMSX project (and its Catapult launcher).
|
|
25
|
+
*/
|
|
26
|
+
export function detectOpenMSXExecutable() {
|
|
27
|
+
if (process.platform === 'win32') {
|
|
28
|
+
return 'openmsx.exe';
|
|
29
|
+
}
|
|
30
|
+
if (process.platform === 'darwin') {
|
|
31
|
+
const appBundlePath = '/Applications/openMSX.app/Contents/MacOS/openmsx';
|
|
32
|
+
if (existsSync(appBundlePath)) {
|
|
33
|
+
return appBundlePath;
|
|
34
|
+
}
|
|
35
|
+
// Fallback: user may have openMSX in PATH (e.g. via Homebrew)
|
|
36
|
+
return 'openmsx';
|
|
37
|
+
}
|
|
38
|
+
// Linux / other POSIX
|
|
39
|
+
return 'openmsx';
|
|
40
|
+
}
|
|
15
41
|
/**
|
|
16
42
|
* Detect the openMSX share directory by checking various methods
|
|
17
43
|
* @returns string - The detected share directory or an empty string if not found
|
|
@@ -24,9 +50,13 @@ export function detectOpenMSXShareDir() {
|
|
|
24
50
|
path.join(os.homedir(), '.openMSX', 'share'),
|
|
25
51
|
'/usr/local/share/openmsx',
|
|
26
52
|
'/usr/share/openmsx',
|
|
27
|
-
// Windows paths
|
|
53
|
+
// Windows paths (appdata, documents, program files, portable)
|
|
54
|
+
...(process.env.APPDATA ? [path.join(process.env.APPDATA, 'openMSX', 'share')] : []),
|
|
55
|
+
...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'openMSX', 'share')] : []),
|
|
28
56
|
path.join(os.homedir(), 'Documents', 'openMSX', 'share'),
|
|
57
|
+
path.join(os.homedir(), 'openMSX', 'share'),
|
|
29
58
|
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'openMSX', 'share'),
|
|
59
|
+
...(process.env['PROGRAMFILES(X86)'] ? [path.join(process.env['PROGRAMFILES(X86)'], 'openMSX', 'share')] : []),
|
|
30
60
|
// macOS paths
|
|
31
61
|
path.join(os.homedir(), 'Library', 'Application Support', 'openMSX', 'share'),
|
|
32
62
|
'/Applications/openMSX.app/Contents/Resources/share',
|
|
@@ -466,6 +496,31 @@ export function parseReplayStatus(response) {
|
|
|
466
496
|
snapshotCount,
|
|
467
497
|
};
|
|
468
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Ensure a directory exists, creating it (and any parents) if necessary.
|
|
501
|
+
* @param dirPath - Absolute path to the directory
|
|
502
|
+
* @returns null on success, or an error message string if creation failed
|
|
503
|
+
*/
|
|
504
|
+
export async function ensureDirectoryExists(dirPath) {
|
|
505
|
+
try {
|
|
506
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
return `Cannot create directory "${dirPath}": ` +
|
|
511
|
+
(error instanceof Error ? error.message : String(error));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Normalize a file path for use inside openMSX TCL commands.
|
|
516
|
+
* Converts Windows backslashes to forward slashes, which TCL accepts on all platforms.
|
|
517
|
+
* On Linux/macOS this is a no-op (no backslashes to replace).
|
|
518
|
+
* @param filePath - File path to normalize
|
|
519
|
+
* @returns Path with forward slashes only
|
|
520
|
+
*/
|
|
521
|
+
export function tclPath(filePath) {
|
|
522
|
+
return filePath.replace(/\\/g, '/');
|
|
523
|
+
}
|
|
469
524
|
/*
|
|
470
525
|
* Sleep for a specified number of milliseconds
|
|
471
526
|
* @param ms - Number of milliseconds to sleep
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nataliapc/mcp-openmsx",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"description": "Model context protocol server for openMSX automation and control",
|
|
5
5
|
"main": "dist/server.js",
|
|
6
6
|
"type": "module",
|
|
@@ -30,13 +30,22 @@
|
|
|
30
30
|
"node": ">=18.0.0"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"build": "tsc && shx chmod +x dist/server.js",
|
|
34
|
-
"watch": "tsc --watch",
|
|
33
|
+
"build": "node_modules/.bin/tsc && shx chmod +x dist/server.js",
|
|
34
|
+
"watch": "node_modules/.bin/tsc --watch",
|
|
35
35
|
"start": "node dist/server.js",
|
|
36
36
|
"dev": "tsx src/server.ts",
|
|
37
37
|
"prepublishOnly": "npm run build",
|
|
38
38
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
39
39
|
},
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"node-expose-sspi": "^0.1.60",
|
|
42
|
+
"sharp": ">=0.32.0"
|
|
43
|
+
},
|
|
44
|
+
"pnpm": {
|
|
45
|
+
"overrides": {
|
|
46
|
+
"sharp": "$sharp"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
40
49
|
"dependencies": {
|
|
41
50
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
42
51
|
"@themaximalist/embeddings.js": "^0.1.3",
|