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/cli.js +447 -115
- package/build/godot/addons/gdharness_editor/tool_executor.gd +3 -0
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +44 -0
- package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +50 -1
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +10 -3
- package/build/godot/operations/godot_operations.gd +0 -2
- package/build/godot/operations/import_pipeline.gd +226 -97
- package/build/index.js +454 -124
- package/build/keeper.js +272 -23
- package/package.json +1 -1
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
|
|
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
|
|
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,
|
|
166
|
-
detached
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|