gdharness 1.1.5 → 1.1.7

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/build/keeper.js CHANGED
@@ -2,7 +2,210 @@
2
2
 
3
3
  // src/keeper.ts
4
4
  import { spawn } from "node:child_process";
5
- import { closeSync, openSync as openSync2 } from "node:fs";
5
+ import { closeSync, existsSync, mkdtempSync, openSync as openSync2, readFileSync as readFileSync2, rmSync as rmSync2 } from "node:fs";
6
+ import { tmpdir as tmpdir2 } from "node:os";
7
+ import { join as join3 } from "node:path";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+
10
+ // src/desktop.ts
11
+ import { createHash } from "node:crypto";
12
+ var SOURCE = `using System;
13
+ using System.ComponentModel;
14
+ using System.IO;
15
+ using System.Runtime.InteropServices;
16
+ using System.Text;
17
+
18
+ public static class GdharnessDesktop {
19
+ const uint GenericAll = 0x10000000;
20
+ const int UseStdHandles = 0x100;
21
+ const int UseShowWindow = 0x1;
22
+ const short ShowNoActivate = 4;
23
+ // No console at all, rather than a hidden one: a console's host would sit on the desktop too.
24
+ const uint DetachedProcess = 0x00000008;
25
+ const uint Infinite = 0xFFFFFFFF;
26
+
27
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
28
+ struct StartupInfo {
29
+ public int cb;
30
+ public string reserved;
31
+ public string desktop;
32
+ public string title;
33
+ public int x, y, xSize, ySize, xCount, yCount, fill, flags;
34
+ public short show, reservedSize;
35
+ public IntPtr reservedBytes, input, output, error;
36
+ }
37
+
38
+ [StructLayout(LayoutKind.Sequential)]
39
+ struct Started {
40
+ public IntPtr process, thread;
41
+ public int processId, threadId;
42
+ }
43
+
44
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
45
+ static extern IntPtr CreateDesktopW(string name, IntPtr device, IntPtr mode, uint flags, uint access, IntPtr attributes);
46
+
47
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
48
+ static extern bool CreateProcessW(string application, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inherit, uint flags, IntPtr environment, string directory, ref StartupInfo startup, out Started started);
49
+
50
+ [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int which);
51
+ [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
52
+ [DllImport("kernel32.dll")] static extern bool GetExitCodeProcess(IntPtr handle, out uint code);
53
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
54
+
55
+ delegate bool EachWindow(IntPtr window, IntPtr unused);
56
+ [DllImport("user32.dll")] static extern bool EnumWindows(EachWindow each, IntPtr unused);
57
+ [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window);
58
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
59
+ [DllImport("user32.dll")] static extern bool LockSetForegroundWindow(uint code);
60
+ const uint LockForeground = 1;
61
+ const uint UnlockForeground = 2;
62
+ const uint TimedOut = 0x102;
63
+
64
+ static bool ShowsAWindow(int processId) {
65
+ bool shown = false;
66
+ EnumWindows(delegate(IntPtr window, IntPtr unused) {
67
+ uint owner;
68
+ GetWindowThreadProcessId(window, out owner);
69
+ if (owner == (uint)processId && IsWindowVisible(window)) {
70
+ shown = true;
71
+ return false;
72
+ }
73
+ return true;
74
+ }, IntPtr.Zero);
75
+ return shown;
76
+ }
77
+
78
+ // Keeps the foreground where it is while the process starts: its own call to take it fails while
79
+ // the foreground is locked, and the user's own Alt+Tab or click still moves it. Released once its
80
+ // window has been showing for a while, since a program takes the foreground as it shows its window
81
+ // and not afterwards, or when it exits, or after two minutes whatever happens.
82
+ static void HoldTheForeground(Started started) {
83
+ DateTime until = DateTime.UtcNow.AddMinutes(2);
84
+ DateTime? shownAt = null;
85
+ while (DateTime.UtcNow < until) {
86
+ if (WaitForSingleObject(started.process, 100) != TimedOut) {
87
+ return;
88
+ }
89
+ if (shownAt == null && ShowsAWindow(started.processId)) {
90
+ shownAt = DateTime.UtcNow;
91
+ }
92
+ if (shownAt != null && DateTime.UtcNow - shownAt.Value > TimeSpan.FromSeconds(10)) {
93
+ return;
94
+ }
95
+ }
96
+ }
97
+
98
+ // Starts the command line, both of its streams on this process's output, writes its pid, and
99
+ // waits for it: the answer is its exit code. On the named desktop when there is one, whose handle
100
+ // is held until then, since a desktop goes when the last handle to it does, and on this process's
101
+ // own otherwise. With noActivate its first window is shown without being activated, which is
102
+ // what a program's first ShowWindow does whatever it asks for when the start names a show state,
103
+ // and the foreground is held while it starts, since Godot also asks for it outright.
104
+ public static int Run(string desktop, string commandLine, string pidFile, bool noActivate) {
105
+ StartupInfo startup = new StartupInfo();
106
+ startup.cb = Marshal.SizeOf(typeof(StartupInfo));
107
+ if (desktop.Length > 0) {
108
+ IntPtr held = CreateDesktopW(desktop, IntPtr.Zero, IntPtr.Zero, 0, GenericAll, IntPtr.Zero);
109
+ if (held == IntPtr.Zero) {
110
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateDesktopW " + desktop);
111
+ }
112
+ startup.desktop = desktop;
113
+ }
114
+ startup.flags = UseStdHandles;
115
+ if (noActivate) {
116
+ startup.flags |= UseShowWindow;
117
+ startup.show = ShowNoActivate;
118
+ }
119
+ startup.input = GetStdHandle(-10);
120
+ startup.output = GetStdHandle(-11);
121
+ startup.error = startup.output;
122
+ Started started;
123
+ if (noActivate) {
124
+ LockSetForegroundWindow(LockForeground);
125
+ }
126
+ try {
127
+ if (!CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, true, DetachedProcess, IntPtr.Zero, null, ref startup, out started)) {
128
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcessW " + commandLine);
129
+ }
130
+ File.WriteAllText(pidFile, started.processId.ToString());
131
+ if (noActivate) {
132
+ HoldTheForeground(started);
133
+ }
134
+ } finally {
135
+ if (noActivate) {
136
+ LockSetForegroundWindow(UnlockForeground);
137
+ }
138
+ }
139
+ WaitForSingleObject(started.process, Infinite);
140
+ uint code;
141
+ GetExitCodeProcess(started.process, out code);
142
+ CloseHandle(started.thread);
143
+ CloseHandle(started.process);
144
+ return unchecked((int)code);
145
+ }
146
+ }
147
+ `;
148
+ function quoted(argument) {
149
+ if (argument !== "" && !/[\s"]/.test(argument)) {
150
+ return argument;
151
+ }
152
+ let text = '"';
153
+ let backslashes = 0;
154
+ for (const character of argument) {
155
+ if (character === "\\") {
156
+ backslashes += 1;
157
+ continue;
158
+ }
159
+ text += character === '"' ? `${"\\".repeat(backslashes * 2 + 1)}"` : `${"\\".repeat(backslashes)}${character}`;
160
+ backslashes = 0;
161
+ }
162
+ return `${text}${"\\".repeat(backslashes * 2)}"`;
163
+ }
164
+ function windowsCommandLine(command, args) {
165
+ return [command, ...args].map(quoted).join(" ");
166
+ }
167
+ function throughHelper(how, command, args, pidFile, errorFile) {
168
+ const spec = {
169
+ desktop: how.desktop ?? "",
170
+ noActivate: how.noActivate === true,
171
+ commandLine: windowsCommandLine(command, args),
172
+ pidFile,
173
+ errorFile,
174
+ source: SOURCE,
175
+ digest: createHash("sha256").update(SOURCE).digest("hex").slice(0, 16)
176
+ };
177
+ const encoded = Buffer.from(JSON.stringify(spec), "utf8").toString("base64");
178
+ const script = [
179
+ "$ErrorActionPreference = 'Stop'",
180
+ `$spec = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json`,
181
+ "try {",
182
+ " $assembly = Join-Path ([IO.Path]::GetTempPath()) ('gdharness-desktop-' + $spec.digest + '.dll')",
183
+ " if (-not (Test-Path -LiteralPath $assembly)) {",
184
+ ' $partial = "$assembly.$PID"',
185
+ " Add-Type -TypeDefinition $spec.source -OutputAssembly $partial",
186
+ " try { Move-Item -LiteralPath $partial -Destination $assembly } catch { Remove-Item -LiteralPath $partial -ErrorAction SilentlyContinue }",
187
+ " }",
188
+ " Add-Type -LiteralPath $assembly",
189
+ " $code = [GdharnessDesktop]::Run($spec.desktop, $spec.commandLine, $spec.pidFile, [bool]$spec.noActivate)",
190
+ "} catch {",
191
+ " [IO.File]::WriteAllText($spec.errorFile, $_.Exception.Message)",
192
+ " exit 1",
193
+ "}",
194
+ "exit $code"
195
+ ].join(`
196
+ `);
197
+ return {
198
+ command: "powershell.exe",
199
+ args: [
200
+ "-NoProfile",
201
+ "-NonInteractive",
202
+ "-ExecutionPolicy",
203
+ "Bypass",
204
+ "-EncodedCommand",
205
+ Buffer.from(script, "utf16le").toString("base64")
206
+ ]
207
+ };
208
+ }
6
209
 
7
210
  // src/outside.ts
8
211
  import { fileURLToPath } from "node:url";
@@ -39,7 +242,7 @@ function readLaunched(printed) {
39
242
  }
40
243
 
41
244
  // src/run-record.ts
42
- import { createHash } from "node:crypto";
245
+ import { createHash as createHash2 } from "node:crypto";
43
246
  import {
44
247
  mkdirSync,
45
248
  openSync,
@@ -104,7 +307,7 @@ function realAsFarAsItExists(path) {
104
307
  function projectKey(projectPath) {
105
308
  const real = realAsFarAsItExists(resolve2(projectPath)).replace(/[\\/]+$/, "");
106
309
  const folded = process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
107
- return createHash("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
310
+ return createHash2("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
108
311
  }
109
312
  function runRecordPath(projectPath, directory = runtimeDirectory()) {
110
313
  return join2(directory, "runs", `note-${projectKey(projectPath)}.json`);
@@ -159,11 +362,12 @@ function recordAt(path) {
159
362
  }
160
363
 
161
364
  // src/keeper.ts
162
- async function startDetached(spec, output) {
365
+ async function startDirectly(spec, output, hidden, detached = true, errors = output) {
163
366
  return await new Promise((resolve) => {
164
367
  const child = spawn(spec.command, [...spec.args], {
165
- stdio: ["ignore", output, output],
166
- detached: true,
368
+ stdio: ["ignore", output, errors],
369
+ detached,
370
+ windowsHide: hidden,
167
371
  env: withChanges(process.env, spec.envChanges)
168
372
  });
169
373
  child.once("error", (error) => {
@@ -174,10 +378,49 @@ async function startDetached(spec, output) {
174
378
  });
175
379
  });
176
380
  }
381
+ async function startThroughHelper(spec, output) {
382
+ const scratch = mkdtempSync(join3(tmpdir2(), "gdharness-desktop-"));
383
+ const pidFile = join3(scratch, "pid");
384
+ const errorFile = join3(scratch, "error");
385
+ const where = spec.desktop === undefined ? "without activation" : `on the ${spec.desktop} desktop`;
386
+ try {
387
+ const wrapper = throughHelper(spec, spec.command, spec.args, pidFile, errorFile);
388
+ const started = await startDirectly({ ...spec, command: wrapper.command, args: wrapper.args }, output, true, false, "ignore");
389
+ if ("error" in started) {
390
+ return started;
391
+ }
392
+ const exited = { now: false };
393
+ started.child.once("exit", () => {
394
+ exited.now = true;
395
+ });
396
+ for (let waited = 0;waited < DESKTOP_START_MS; waited += 50) {
397
+ const pid = existsSync(pidFile) ? Number(readFileSync2(pidFile, "utf8").trim()) : Number.NaN;
398
+ if (Number.isInteger(pid) && pid > 0) {
399
+ return { child: started.child, pid };
400
+ }
401
+ if (exited.now) {
402
+ const why = existsSync(errorFile) ? readFileSync2(errorFile, "utf8").trim() : "it exited saying nothing";
403
+ return { error: `it could not be started ${where}: ${why}` };
404
+ }
405
+ await delay(50);
406
+ }
407
+ started.child.kill();
408
+ return { error: `it was not started ${where} within ${DESKTOP_START_MS} ms` };
409
+ } finally {
410
+ rmSync2(scratch, { recursive: true, force: true });
411
+ }
412
+ }
413
+ var DESKTOP_START_MS = 60000;
414
+ async function startDetached(spec, output, hidden) {
415
+ return process.platform === "win32" && (spec.desktop !== undefined || spec.noActivate === true) ? await startThroughHelper(spec, output) : await startDirectly(spec, output, hidden);
416
+ }
417
+ function needsItsHelperKept(spec) {
418
+ return process.platform === "win32" && (spec.desktop !== undefined || spec.noActivate === true);
419
+ }
177
420
  async function launch() {
178
421
  const spec = await receivedSpec();
179
- if (spec.run === undefined) {
180
- const started = await startDetached(spec, "ignore");
422
+ if (spec.run === undefined && !needsItsHelperKept(spec)) {
423
+ const started = await startDetached(spec, "ignore", true);
181
424
  if ("error" in started) {
182
425
  process.stdout.write(`error ${started.error}
183
426
  `);
@@ -225,18 +468,20 @@ async function launch() {
225
468
  }
226
469
  async function keep() {
227
470
  const spec = await receivedSpec();
228
- if (spec.run === undefined) {
229
- process.stdout.write(`error a keeper was started without a run
471
+ const run = spec.run;
472
+ if (run === undefined && !needsItsHelperKept(spec)) {
473
+ process.stdout.write(`error a keeper was started without a run or a helper to keep
230
474
  `);
231
475
  process.exit(1);
232
476
  }
233
- const run = spec.run;
234
- const transcript = openSync2(run.transcript, "a");
477
+ const transcript = run === undefined ? "ignore" : openSync2(run.transcript, "a");
235
478
  let started;
236
479
  try {
237
- started = await startDetached(spec, transcript);
480
+ started = await startDetached(spec, transcript, run === undefined);
238
481
  } finally {
239
- closeSync(transcript);
482
+ if (transcript !== "ignore") {
483
+ closeSync(transcript);
484
+ }
240
485
  }
241
486
  if ("error" in started) {
242
487
  process.stdout.write(`error ${started.error}
@@ -244,16 +489,20 @@ async function keep() {
244
489
  process.exit(1);
245
490
  }
246
491
  const { child, pid } = started;
247
- writeRunRecord({
248
- pid,
249
- transcript: run.transcript,
250
- startedAt: run.startedAt,
251
- projectPath: run.projectPath,
252
- arguments: spec.args,
253
- command: spec.command
254
- });
492
+ if (run !== undefined) {
493
+ writeRunRecord({
494
+ pid,
495
+ transcript: run.transcript,
496
+ startedAt: run.startedAt,
497
+ projectPath: run.projectPath,
498
+ arguments: spec.args,
499
+ command: spec.command
500
+ });
501
+ }
255
502
  child.once("exit", (code, signal) => {
256
- recordRunEnded(run.projectPath, pid, { exitCode: code, exitSignal: code === null ? signal : null });
503
+ if (run !== undefined) {
504
+ recordRunEnded(run.projectPath, pid, { exitCode: code, exitSignal: code === null ? signal : null });
505
+ }
257
506
  process.exit(0);
258
507
  });
259
508
  process.stdout.write(`pid ${pid}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",