@mindexec/cli 0.2.493 → 0.2.495
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/electron/main.cjs +108 -7
- package/electron/native-renderer-owner-smoke.mjs +387 -0
- package/electron/native-renderer-owner.cjs +1044 -0
- package/electron/preload.cjs +27 -12
- package/electron/source-smoke.mjs +63 -0
- package/electron/windows-package-smoke.mjs +92 -0
- package/package.json +18 -8
- package/remote-fast/osx-arm64/mindexec-remote-fast.dll +0 -0
- package/remote-fast/osx-x64/mindexec-remote-fast.dll +0 -0
- package/remote-fast/win-x64/mindexec-remote-fast.dll +0 -0
- package/scripts/desktop-update-publisher-smoke.mjs +33 -5
- package/scripts/publish-mindexec-desktop-updates.mjs +34 -5
- package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +3 -0
- package/wwwroot/_content/MindExecution.Shared/js/mind-map-logic-workers.js +21 -1
- package/wwwroot/_content/MindExecution.Shared/js/mind-map-native-render-mirror.js +586 -0
- package/wwwroot/_content/MindExecution.Shared/native-core/native-core-manifest.json +2 -2
- package/wwwroot/_framework/{MindExecution.Core.60vfpag0ii.dll → MindExecution.Core.x2u2ssgfpb.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Kernel.mvd5iguar3.dll → MindExecution.Kernel.ii6sbi4gnx.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Admin.vdu428rgb7.dll → MindExecution.Plugins.Admin.f8zjm0p5bt.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Business.ew1bh09dyd.dll → MindExecution.Plugins.Business.dlkfqt93hm.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Concept.jk98j1djoc.dll → MindExecution.Plugins.Concept.1dauzp0wt9.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Directory.6ub1ud4o4q.dll → MindExecution.Plugins.Directory.3h32owxein.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.PlanMaster.0as3sypjys.dll → MindExecution.Plugins.PlanMaster.xcrnct9qgp.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.YouTube.b12xkgn7e0.dll → MindExecution.Plugins.YouTube.md7aqqgjj2.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Shared.2fytw4afkm.dll → MindExecution.Shared.a9wtfnqxlv.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Web.f6sfehwfzb.dll → MindExecution.Web.xg7sbkw72l.dll} +0 -0
- package/wwwroot/_framework/blazor.boot.json +21 -21
- package/wwwroot/index.html +2 -1
- package/wwwroot/service-worker-assets.js +30 -26
- package/wwwroot/service-worker.js +1 -1
package/electron/main.cjs
CHANGED
|
@@ -7,6 +7,7 @@ const http = require('http');
|
|
|
7
7
|
const net = require('net');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const workspaceState = require('../desktop-workspace-state.cjs');
|
|
10
|
+
const { createNativeRendererOwner } = require('./native-renderer-owner.cjs');
|
|
10
11
|
const { createProductUpdateManager } = require('./update-manager.cjs');
|
|
11
12
|
const {
|
|
12
13
|
DEFAULT_UPDATE_INTERVAL_MS,
|
|
@@ -33,6 +34,7 @@ let desktopWorkspaceStatePath = '';
|
|
|
33
34
|
let productUpdates = null;
|
|
34
35
|
let recurringProductUpdates = null;
|
|
35
36
|
let updateInstallSequenceStarted = false;
|
|
37
|
+
let nativeRenderer = null;
|
|
36
38
|
|
|
37
39
|
function resolveDesktopIconPath() {
|
|
38
40
|
const iconPath = path.resolve(__dirname, '..', 'wwwroot', 'icon-512.png');
|
|
@@ -61,6 +63,28 @@ function resolveNativeCoreMode() {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
function resolveNativeRendererMode() {
|
|
67
|
+
const validModes = new Set(['off', 'mirror']);
|
|
68
|
+
const environmentMode = String(
|
|
69
|
+
process.env.MINDEXEC_NATIVE_RENDER_MODE || process.env.MINDEXEC_NATIVE_RENDERER_MODE || ''
|
|
70
|
+
).trim().toLowerCase();
|
|
71
|
+
if (validModes.has(environmentMode)) return environmentMode;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const packagePath = app.isPackaged
|
|
75
|
+
? path.join(app.getAppPath(), 'package.json')
|
|
76
|
+
: path.resolve(__dirname, '..', 'package.json');
|
|
77
|
+
const packageInfo = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
78
|
+
const configuredMode = app.isPackaged
|
|
79
|
+
? packageInfo.nativeRenderMode
|
|
80
|
+
: packageInfo.build?.extraMetadata?.nativeRenderMode;
|
|
81
|
+
const normalizedMode = String(configuredMode || '').trim().toLowerCase();
|
|
82
|
+
return validModes.has(normalizedMode) ? normalizedMode : 'off';
|
|
83
|
+
} catch {
|
|
84
|
+
return 'off';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
function parsePort(value) {
|
|
65
89
|
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
66
90
|
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535 ? parsed : 0;
|
|
@@ -236,19 +260,23 @@ async function prepareBundledRuntime() {
|
|
|
236
260
|
return waitForBundledBridge();
|
|
237
261
|
}
|
|
238
262
|
|
|
239
|
-
function
|
|
263
|
+
function assertTrustedDesktopIpc(event, feature) {
|
|
240
264
|
const senderUrl = event?.senderFrame?.url || event?.sender?.getURL?.() || '';
|
|
241
265
|
let sender;
|
|
242
266
|
try {
|
|
243
267
|
sender = new URL(senderUrl);
|
|
244
268
|
} catch {
|
|
245
|
-
throw new Error(
|
|
269
|
+
throw new Error(`desktop-${feature}-ipc-sender-invalid`);
|
|
246
270
|
}
|
|
247
271
|
if (sender.protocol !== 'http:' || sender.hostname !== '127.0.0.1' || sender.port !== String(activeBridgePort)) {
|
|
248
|
-
throw new Error(
|
|
272
|
+
throw new Error(`desktop-${feature}-ipc-sender-untrusted`);
|
|
249
273
|
}
|
|
250
274
|
}
|
|
251
275
|
|
|
276
|
+
function assertTrustedUpdateIpc(event) {
|
|
277
|
+
assertTrustedDesktopIpc(event, 'update');
|
|
278
|
+
}
|
|
279
|
+
|
|
252
280
|
function registerUpdateIpc() {
|
|
253
281
|
const handle = (channel, callback) => {
|
|
254
282
|
ipcMain.handle(channel, async (event) => {
|
|
@@ -261,6 +289,50 @@ function registerUpdateIpc() {
|
|
|
261
289
|
handle('updates:install', () => productUpdates?.install() || Promise.resolve({ state: 'unavailable' }));
|
|
262
290
|
}
|
|
263
291
|
|
|
292
|
+
function sendNativeRendererStatus(status) {
|
|
293
|
+
appendDesktopLog(
|
|
294
|
+
`[native-renderer] mode=${status.mode} ready=${status.ready} backend=${status.backend || ''} ` +
|
|
295
|
+
`generation=${status.engineGeneration} accepted=${status.acceptedFrames} rejected=${status.rejectedFrames}` +
|
|
296
|
+
`${status.lastError ? ` error=${status.lastError}` : ''}`
|
|
297
|
+
);
|
|
298
|
+
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
|
|
299
|
+
mainWindow.webContents.send('native-renderer:status', status);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function initializeNativeRenderer() {
|
|
304
|
+
nativeRenderer = createNativeRendererOwner({
|
|
305
|
+
mode: resolveNativeRendererMode(),
|
|
306
|
+
appIsPackaged: app.isPackaged,
|
|
307
|
+
resourcesPath: process.resourcesPath,
|
|
308
|
+
packageRoot: path.resolve(__dirname, '..'),
|
|
309
|
+
onStatus: sendNativeRendererStatus,
|
|
310
|
+
log: appendDesktopLog
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function registerNativeRendererIpc() {
|
|
315
|
+
const handle = (channel, callback) => {
|
|
316
|
+
ipcMain.handle(channel, async (event, payload) => {
|
|
317
|
+
assertTrustedDesktopIpc(event, 'native-renderer');
|
|
318
|
+
return callback(payload);
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
handle('native-renderer:get-status', () => nativeRenderer?.getStatus() || {
|
|
322
|
+
mode: 'off',
|
|
323
|
+
ready: false,
|
|
324
|
+
backend: '',
|
|
325
|
+
engineGeneration: 0,
|
|
326
|
+
acceptedFrames: 0,
|
|
327
|
+
rejectedFrames: 0,
|
|
328
|
+
lastError: '',
|
|
329
|
+
processId: 0
|
|
330
|
+
});
|
|
331
|
+
handle('native-renderer:snapshot', scene => nativeRenderer?.sendSnapshot(scene));
|
|
332
|
+
handle('native-renderer:camera', frame => nativeRenderer?.sendCamera(frame));
|
|
333
|
+
handle('native-renderer:detach', owner => nativeRenderer?.detach(owner));
|
|
334
|
+
}
|
|
335
|
+
|
|
264
336
|
function sendUpdateStatus(status) {
|
|
265
337
|
appendDesktopLog(
|
|
266
338
|
`[update] state=${status.state} current=${status.currentVersion || ''} ` +
|
|
@@ -389,6 +461,23 @@ async function waitForNativeCoreSmoke(browserWindow, expectedMode) {
|
|
|
389
461
|
throw new Error(`Native core did not become ready in ${expectedMode} mode: ${JSON.stringify(lastSnapshot)}`);
|
|
390
462
|
}
|
|
391
463
|
|
|
464
|
+
async function waitForNativeRendererSmoke(expectedMode) {
|
|
465
|
+
if (expectedMode === 'off') return nativeRenderer?.getStatus() || null;
|
|
466
|
+
const deadline = Date.now() + 30_000;
|
|
467
|
+
let lastSnapshot = null;
|
|
468
|
+
while (Date.now() < deadline) {
|
|
469
|
+
lastSnapshot = nativeRenderer?.getStatus() || null;
|
|
470
|
+
if (lastSnapshot?.lastError) {
|
|
471
|
+
throw new Error(`Native renderer smoke failed: ${lastSnapshot.lastError}`);
|
|
472
|
+
}
|
|
473
|
+
if (lastSnapshot?.ready === true && Number(lastSnapshot.acceptedFrames || 0) > 0) {
|
|
474
|
+
return lastSnapshot;
|
|
475
|
+
}
|
|
476
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
477
|
+
}
|
|
478
|
+
throw new Error(`Native renderer did not accept a board frame: ${JSON.stringify(lastSnapshot)}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
392
481
|
async function stopBundledBridge() {
|
|
393
482
|
const child = bridgeChild;
|
|
394
483
|
bridgeChild = null;
|
|
@@ -448,9 +537,10 @@ async function prepareRuntimeForUpdateInstall() {
|
|
|
448
537
|
recurringProductUpdates?.stop();
|
|
449
538
|
isQuitting = true;
|
|
450
539
|
appendDesktopLog('[update] stopping bundled runtime before installer launch');
|
|
540
|
+
await nativeRenderer?.stop();
|
|
451
541
|
await stopBundledBridge();
|
|
452
542
|
await waitForBundledRuntimePortsReleased();
|
|
453
|
-
appendDesktopLog('[update] bundled runtime and ports are fully released');
|
|
543
|
+
appendDesktopLog('[update] native renderer, bundled runtime, and ports are fully released');
|
|
454
544
|
}
|
|
455
545
|
|
|
456
546
|
async function recoverRuntimeAfterUpdateInstallError(error) {
|
|
@@ -460,11 +550,15 @@ async function recoverRuntimeAfterUpdateInstallError(error) {
|
|
|
460
550
|
if (!bridgeChild || bridgeChild.exitCode !== null) {
|
|
461
551
|
await prepareBundledRuntime();
|
|
462
552
|
}
|
|
553
|
+
await nativeRenderer?.start();
|
|
463
554
|
recurringProductUpdates?.start();
|
|
464
555
|
}
|
|
465
556
|
|
|
466
557
|
async function launchDesktop() {
|
|
467
|
-
const status = await
|
|
558
|
+
const [status] = await Promise.all([
|
|
559
|
+
prepareBundledRuntime(),
|
|
560
|
+
nativeRenderer?.start()
|
|
561
|
+
]);
|
|
468
562
|
const window = createMainWindow();
|
|
469
563
|
const nativeCoreMode = resolveNativeCoreMode();
|
|
470
564
|
const nativeCoreQuery = nativeCoreMode === 'off'
|
|
@@ -475,6 +569,7 @@ async function launchDesktop() {
|
|
|
475
569
|
|
|
476
570
|
if (process.env.MINDEXEC_DESKTOP_SMOKE === '1') {
|
|
477
571
|
const nativeCore = await waitForNativeCoreSmoke(window, nativeCoreMode);
|
|
572
|
+
const nativeRendererStatus = await waitForNativeRendererSmoke(nativeRenderer?.getStatus().mode || 'off');
|
|
478
573
|
writeSmokeResult({
|
|
479
574
|
ok: true,
|
|
480
575
|
appVersion: app.getVersion(),
|
|
@@ -486,6 +581,7 @@ async function launchDesktop() {
|
|
|
486
581
|
workspace: activeWorkspace,
|
|
487
582
|
url: appUrl,
|
|
488
583
|
nativeCore,
|
|
584
|
+
nativeRenderer: nativeRendererStatus,
|
|
489
585
|
desktopUpdate: productUpdates?.getStatus() || null
|
|
490
586
|
});
|
|
491
587
|
setTimeout(() => app.quit(), 250).unref();
|
|
@@ -514,20 +610,25 @@ if (!hasSingleInstanceLock) {
|
|
|
514
610
|
});
|
|
515
611
|
|
|
516
612
|
app.on('before-quit', (event) => {
|
|
517
|
-
|
|
613
|
+
const bridgeRunning = Boolean(bridgeChild && bridgeChild.exitCode === null);
|
|
614
|
+
const nativeRendererRunning = nativeRenderer?.isRunning() === true;
|
|
615
|
+
if (isQuitting || (!bridgeRunning && !nativeRendererRunning)) {
|
|
518
616
|
return;
|
|
519
617
|
}
|
|
520
618
|
|
|
521
619
|
event.preventDefault();
|
|
522
620
|
recurringProductUpdates?.stop();
|
|
523
621
|
isQuitting = true;
|
|
524
|
-
|
|
622
|
+
Promise.all([nativeRenderer?.stop(), stopBundledBridge()])
|
|
623
|
+
.finally(() => app.exit(requestedExitCode));
|
|
525
624
|
});
|
|
526
625
|
|
|
527
626
|
app.whenReady()
|
|
528
627
|
.then(async () => {
|
|
529
628
|
initializeProductUpdates();
|
|
629
|
+
initializeNativeRenderer();
|
|
530
630
|
registerUpdateIpc();
|
|
631
|
+
registerNativeRendererIpc();
|
|
531
632
|
void productUpdates.checkAtStartup().catch(error => {
|
|
532
633
|
appendDesktopLog(`[update:startup-error] ${error?.stack || error}`);
|
|
533
634
|
});
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import protocol from './native-renderer-owner.cjs';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
HEADER_BYTES,
|
|
8
|
+
PROTOCOL_MAGIC,
|
|
9
|
+
PROTOCOL_VERSION,
|
|
10
|
+
REQUEST_TYPE,
|
|
11
|
+
RESPONSE_TYPE,
|
|
12
|
+
createNativeRendererOwner,
|
|
13
|
+
framePayload
|
|
14
|
+
} = protocol;
|
|
15
|
+
|
|
16
|
+
function stringU16(value) {
|
|
17
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
18
|
+
const output = Buffer.allocUnsafe(2 + bytes.length);
|
|
19
|
+
output.writeUInt16LE(bytes.length, 0);
|
|
20
|
+
bytes.copy(output, 2);
|
|
21
|
+
return output;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requestOwnerPayload(payload) {
|
|
25
|
+
const boardIdLength = payload.readUInt16LE(0);
|
|
26
|
+
return payload.subarray(0, 2 + boardIdLength + 32);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function responseReady(backend) {
|
|
30
|
+
return framePayload(RESPONSE_TYPE.ready, stringU16(backend));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function responseAccepted(requestType, ownerPayload) {
|
|
34
|
+
const payload = Buffer.allocUnsafe(2 + ownerPayload.length);
|
|
35
|
+
payload.writeUInt16LE(requestType, 0);
|
|
36
|
+
ownerPayload.copy(payload, 2);
|
|
37
|
+
return framePayload(RESPONSE_TYPE.accepted, payload);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function responseShutdown() {
|
|
41
|
+
return framePayload(RESPONSE_TYPE.shutdown, Buffer.alloc(0));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class FakeNativeRendererChild extends EventEmitter {
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
super();
|
|
47
|
+
this.pid = options.pid || 4242;
|
|
48
|
+
this.exitCode = null;
|
|
49
|
+
this.stdin = new PassThrough();
|
|
50
|
+
this.stdout = new PassThrough();
|
|
51
|
+
this.stderr = new PassThrough();
|
|
52
|
+
this.requests = [];
|
|
53
|
+
this.input = Buffer.alloc(0);
|
|
54
|
+
this.holdRequestResponses = false;
|
|
55
|
+
this.autoExitOnShutdown = options.autoExitOnShutdown !== false;
|
|
56
|
+
this.autoExitOnKill = options.autoExitOnKill !== false;
|
|
57
|
+
this.killSignals = [];
|
|
58
|
+
this.stdin.on('data', chunk => this.handleInput(chunk));
|
|
59
|
+
setImmediate(() => this.stdout.write(responseReady('fake-d3d11')));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleInput(chunk) {
|
|
63
|
+
this.input = this.input.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.input, chunk]);
|
|
64
|
+
while (this.input.length >= HEADER_BYTES) {
|
|
65
|
+
const payloadLength = this.input.readUInt32LE(8);
|
|
66
|
+
const frameLength = HEADER_BYTES + payloadLength;
|
|
67
|
+
if (this.input.length < frameLength) return;
|
|
68
|
+
const frame = Buffer.from(this.input.subarray(0, frameLength));
|
|
69
|
+
this.input = this.input.subarray(frameLength);
|
|
70
|
+
this.requests.push(frame);
|
|
71
|
+
const requestType = frame.readUInt16LE(6);
|
|
72
|
+
const payload = frame.subarray(HEADER_BYTES);
|
|
73
|
+
if (requestType === REQUEST_TYPE.shutdown) {
|
|
74
|
+
this.stdout.write(responseShutdown());
|
|
75
|
+
if (this.autoExitOnShutdown) {
|
|
76
|
+
setImmediate(() => this.exit(0, null));
|
|
77
|
+
}
|
|
78
|
+
} else if (!this.holdRequestResponses) {
|
|
79
|
+
this.stdout.write(responseAccepted(requestType, requestOwnerPayload(payload)));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
kill(signal) {
|
|
85
|
+
this.killSignals.push(signal);
|
|
86
|
+
if (this.autoExitOnKill) {
|
|
87
|
+
setImmediate(() => this.exit(signal === 'SIGKILL' ? 1 : 0, signal));
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
exit(code, signal) {
|
|
93
|
+
if (this.exitCode !== null) return;
|
|
94
|
+
this.exitCode = code;
|
|
95
|
+
this.emit('exit', code, signal);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let fakeChild = null;
|
|
100
|
+
function fakeSpawn() {
|
|
101
|
+
fakeChild = new FakeNativeRendererChild();
|
|
102
|
+
return fakeChild;
|
|
103
|
+
}
|
|
104
|
+
fakeSpawn.__allowNonWindowsForTest = true;
|
|
105
|
+
|
|
106
|
+
const statusEvents = [];
|
|
107
|
+
const owner = createNativeRendererOwner({
|
|
108
|
+
mode: 'mirror',
|
|
109
|
+
appIsPackaged: false,
|
|
110
|
+
executablePath: process.execPath,
|
|
111
|
+
spawnProcess: fakeSpawn,
|
|
112
|
+
readyTimeoutMs: 1_000,
|
|
113
|
+
responseTimeoutMs: 100,
|
|
114
|
+
stopTimeoutMs: 1_000,
|
|
115
|
+
onStatus: status => statusEvents.push(status)
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const readyStatus = await owner.start();
|
|
119
|
+
assert.equal(readyStatus.ready, true, 'The native renderer owner did not accept READY.');
|
|
120
|
+
assert.equal(readyStatus.backend, 'fake-d3d11', 'READY backend parsing drifted.');
|
|
121
|
+
assert.equal(readyStatus.engineGeneration, 1, 'The first owned process must use generation 1.');
|
|
122
|
+
assert.equal(readyStatus.processId, 4242, 'The owner must report the exact child process id.');
|
|
123
|
+
|
|
124
|
+
const commonOwner = {
|
|
125
|
+
boardId: 'board-한글',
|
|
126
|
+
engineGeneration: readyStatus.engineGeneration,
|
|
127
|
+
boardEpoch: 7,
|
|
128
|
+
boardRevision: 11,
|
|
129
|
+
requestSequence: 13
|
|
130
|
+
};
|
|
131
|
+
const camera = {
|
|
132
|
+
x: 10,
|
|
133
|
+
y: -20,
|
|
134
|
+
z: 1_200,
|
|
135
|
+
near: 1,
|
|
136
|
+
far: 10_000,
|
|
137
|
+
fov: 45,
|
|
138
|
+
viewportW: 1_440,
|
|
139
|
+
viewportH: 960,
|
|
140
|
+
zoom: 1.25
|
|
141
|
+
};
|
|
142
|
+
const snapshotResult = await owner.sendSnapshot({
|
|
143
|
+
owner: commonOwner,
|
|
144
|
+
camera,
|
|
145
|
+
nodes: [{
|
|
146
|
+
handle: 1,
|
|
147
|
+
x: 100,
|
|
148
|
+
y: 200,
|
|
149
|
+
z: 0,
|
|
150
|
+
width: 320,
|
|
151
|
+
height: 180,
|
|
152
|
+
fillRgba: 0x89abcdef
|
|
153
|
+
}],
|
|
154
|
+
edges: [{
|
|
155
|
+
handle: 2,
|
|
156
|
+
sourceHandle: 1,
|
|
157
|
+
targetHandle: 1,
|
|
158
|
+
z: -1,
|
|
159
|
+
width: 3,
|
|
160
|
+
colorRgba: 0x102030ff,
|
|
161
|
+
points: [{ x: 100, y: 200 }, { x: 260, y: 290 }]
|
|
162
|
+
}]
|
|
163
|
+
});
|
|
164
|
+
assert.equal(snapshotResult.accepted, true, 'A valid snapshot was not acknowledged.');
|
|
165
|
+
assert.equal(snapshotResult.acceptedFrames, 1, 'Accepted snapshot count drifted.');
|
|
166
|
+
|
|
167
|
+
const snapshotFrame = fakeChild.requests[0];
|
|
168
|
+
assert.equal(snapshotFrame.readUInt32LE(0), PROTOCOL_MAGIC, 'Snapshot magic drifted.');
|
|
169
|
+
assert.equal(snapshotFrame.readUInt16LE(4), PROTOCOL_VERSION, 'Snapshot version drifted.');
|
|
170
|
+
assert.equal(snapshotFrame.readUInt16LE(6), REQUEST_TYPE.snapshot, 'Snapshot request type drifted.');
|
|
171
|
+
assert.equal(snapshotFrame.readUInt32LE(8), snapshotFrame.length - HEADER_BYTES,
|
|
172
|
+
'Snapshot payload length does not match its frame.');
|
|
173
|
+
let offset = HEADER_BYTES;
|
|
174
|
+
const boardIdLength = snapshotFrame.readUInt16LE(offset);
|
|
175
|
+
offset += 2;
|
|
176
|
+
assert.equal(snapshotFrame.toString('utf8', offset, offset + boardIdLength), commonOwner.boardId,
|
|
177
|
+
'UTF-8 board id encoding drifted.');
|
|
178
|
+
offset += boardIdLength;
|
|
179
|
+
assert.equal(snapshotFrame.readBigUInt64LE(offset), 1n, 'Engine generation encoding drifted.');
|
|
180
|
+
assert.equal(snapshotFrame.readBigUInt64LE(offset + 8), 7n, 'Board epoch encoding drifted.');
|
|
181
|
+
assert.equal(snapshotFrame.readBigUInt64LE(offset + 16), 11n, 'Board revision encoding drifted.');
|
|
182
|
+
assert.equal(snapshotFrame.readBigUInt64LE(offset + 24), 13n, 'Request sequence encoding drifted.');
|
|
183
|
+
offset += 32;
|
|
184
|
+
assert.equal(snapshotFrame.readDoubleLE(offset), camera.x, 'Camera x encoding drifted.');
|
|
185
|
+
assert.equal(snapshotFrame.readDoubleLE(offset + 56), camera.viewportH, 'Camera viewport height encoding drifted.');
|
|
186
|
+
assert.equal(snapshotFrame.readDoubleLE(offset + 64), camera.zoom, 'Camera zoom encoding drifted.');
|
|
187
|
+
offset += 72;
|
|
188
|
+
assert.equal(snapshotFrame.readUInt32LE(offset), 1, 'Snapshot node count drifted.');
|
|
189
|
+
assert.equal(snapshotFrame.readUInt32LE(offset + 4), 1, 'Snapshot edge count drifted.');
|
|
190
|
+
assert.equal(snapshotFrame.readUInt32LE(offset + 8), 2, 'Snapshot point count drifted.');
|
|
191
|
+
offset += 12;
|
|
192
|
+
assert.equal(snapshotFrame.readUInt32LE(offset), 1, 'Node handle encoding drifted.');
|
|
193
|
+
assert.equal(snapshotFrame.readDoubleLE(offset + 4), 100, 'Node x encoding drifted.');
|
|
194
|
+
assert.equal(snapshotFrame.readUInt32LE(offset + 44), 0x89abcdef, 'Node color encoding drifted.');
|
|
195
|
+
offset += 48;
|
|
196
|
+
assert.equal(snapshotFrame.readUInt32LE(offset), 2, 'Edge handle encoding drifted.');
|
|
197
|
+
assert.equal(snapshotFrame.readUInt32LE(offset + 4), 1, 'Edge source handle encoding drifted.');
|
|
198
|
+
assert.equal(snapshotFrame.readUInt32LE(offset + 8), 1, 'Edge target handle encoding drifted.');
|
|
199
|
+
assert.equal(snapshotFrame.readDoubleLE(offset + 12), -1, 'Edge z encoding drifted.');
|
|
200
|
+
assert.equal(snapshotFrame.readDoubleLE(offset + 20), 3, 'Edge width encoding drifted.');
|
|
201
|
+
assert.equal(snapshotFrame.readUInt32LE(offset + 28), 0x102030ff, 'Edge color encoding drifted.');
|
|
202
|
+
assert.equal(snapshotFrame.readUInt32LE(offset + 32), 2, 'Edge point count encoding drifted.');
|
|
203
|
+
assert.equal(snapshotFrame.readDoubleLE(offset + 36), 100, 'First edge point x encoding drifted.');
|
|
204
|
+
assert.equal(snapshotFrame.readDoubleLE(offset + 60), 290, 'Second edge point y encoding drifted.');
|
|
205
|
+
assert.equal(offset + 68, snapshotFrame.length, 'Snapshot record size drifted.');
|
|
206
|
+
|
|
207
|
+
const requestCountBeforeStaleFrame = fakeChild.requests.length;
|
|
208
|
+
const staleResult = await owner.sendCamera({
|
|
209
|
+
owner: { ...commonOwner, engineGeneration: readyStatus.engineGeneration + 1, requestSequence: 14 },
|
|
210
|
+
camera
|
|
211
|
+
});
|
|
212
|
+
assert.equal(staleResult.accepted, false, 'A stale renderer generation was accepted.');
|
|
213
|
+
assert.equal(staleResult.reason, 'native-renderer-engine-generation-mismatch',
|
|
214
|
+
'Stale generation rejection reason drifted.');
|
|
215
|
+
assert.equal(fakeChild.requests.length, requestCountBeforeStaleFrame,
|
|
216
|
+
'A stale renderer generation reached the child process.');
|
|
217
|
+
|
|
218
|
+
const cameraResult = await owner.sendCamera({
|
|
219
|
+
owner: { ...commonOwner, requestSequence: 15 },
|
|
220
|
+
camera: { ...camera, x: 42 }
|
|
221
|
+
});
|
|
222
|
+
assert.equal(cameraResult.accepted, true, 'A valid camera frame was not acknowledged.');
|
|
223
|
+
assert.equal(cameraResult.acceptedFrames, 2, 'Accepted camera count drifted.');
|
|
224
|
+
assert.equal(cameraResult.rejectedFrames, 1, 'Rejected stale frame count drifted.');
|
|
225
|
+
|
|
226
|
+
const detachResult = await owner.detach({ ...commonOwner, requestSequence: 16 });
|
|
227
|
+
assert.equal(detachResult.accepted, true, 'A valid board detach was not acknowledged.');
|
|
228
|
+
assert.equal(detachResult.acceptedFrames, 2, 'Detach must not count as a rendered frame.');
|
|
229
|
+
|
|
230
|
+
fakeChild.holdRequestResponses = true;
|
|
231
|
+
const pendingKeepAlive = setInterval(() => undefined, 25);
|
|
232
|
+
const boundedResults = await Promise.all(Array.from({ length: 9 }, (_, index) => owner.sendCamera({
|
|
233
|
+
owner: { ...commonOwner, requestSequence: 100 + index },
|
|
234
|
+
camera
|
|
235
|
+
})));
|
|
236
|
+
clearInterval(pendingKeepAlive);
|
|
237
|
+
assert.equal(boundedResults.filter(result => result.reason === 'native-renderer-pending-limit').length, 1,
|
|
238
|
+
'The owner must reject work beyond its bounded pending request count.');
|
|
239
|
+
assert.equal(boundedResults.filter(result => result.reason === 'native-renderer-response-timeout').length, 8,
|
|
240
|
+
'The bounded pending requests did not resolve through their timeout path.');
|
|
241
|
+
fakeChild.holdRequestResponses = false;
|
|
242
|
+
|
|
243
|
+
const stoppedStatus = await owner.stop();
|
|
244
|
+
assert.equal(stoppedStatus.ready, false, 'Stopped renderer still reports ready.');
|
|
245
|
+
assert.equal(stoppedStatus.processId, 0, 'Stopped renderer still reports a child pid.');
|
|
246
|
+
assert.equal(fakeChild.requests.at(-1).readUInt16LE(6), REQUEST_TYPE.shutdown,
|
|
247
|
+
'Owned shutdown did not use the protocol shutdown frame.');
|
|
248
|
+
|
|
249
|
+
const missingOwner = createNativeRendererOwner({
|
|
250
|
+
mode: 'mirror',
|
|
251
|
+
executablePath: `${process.execPath}.definitely-missing`,
|
|
252
|
+
spawnProcess: fakeSpawn
|
|
253
|
+
});
|
|
254
|
+
const missingStatus = await missingOwner.start();
|
|
255
|
+
assert.equal(missingStatus.ready, false, 'A missing renderer executable reported ready.');
|
|
256
|
+
assert.match(missingStatus.lastError, /^native-renderer-executable-missing:/,
|
|
257
|
+
'A missing renderer executable must fail open with a diagnosable status.');
|
|
258
|
+
|
|
259
|
+
function controlledSpawnFactory(options) {
|
|
260
|
+
const children = [];
|
|
261
|
+
const spawnControlled = () => {
|
|
262
|
+
const child = new FakeNativeRendererChild({
|
|
263
|
+
...options,
|
|
264
|
+
pid: (options.pid || 5000) + children.length
|
|
265
|
+
});
|
|
266
|
+
children.push(child);
|
|
267
|
+
return child;
|
|
268
|
+
};
|
|
269
|
+
spawnControlled.__allowNonWindowsForTest = true;
|
|
270
|
+
return { children, spawnControlled };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const stoppingFactory = controlledSpawnFactory({
|
|
274
|
+
pid: 5100,
|
|
275
|
+
autoExitOnShutdown: false,
|
|
276
|
+
autoExitOnKill: true
|
|
277
|
+
});
|
|
278
|
+
const stoppingOwner = createNativeRendererOwner({
|
|
279
|
+
mode: 'mirror',
|
|
280
|
+
executablePath: process.execPath,
|
|
281
|
+
spawnProcess: stoppingFactory.spawnControlled,
|
|
282
|
+
readyTimeoutMs: 1_000,
|
|
283
|
+
responseTimeoutMs: 100,
|
|
284
|
+
stopTimeoutMs: 1_000,
|
|
285
|
+
forceExitTimeoutMs: 1_000
|
|
286
|
+
});
|
|
287
|
+
const stoppingReady = await stoppingOwner.start();
|
|
288
|
+
const stoppingChild = stoppingFactory.children[0];
|
|
289
|
+
const stoppingPromise = stoppingOwner.stop();
|
|
290
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
291
|
+
const sendWhileStopping = await stoppingOwner.sendCamera({
|
|
292
|
+
owner: {
|
|
293
|
+
...commonOwner,
|
|
294
|
+
engineGeneration: stoppingReady.engineGeneration,
|
|
295
|
+
requestSequence: 201
|
|
296
|
+
},
|
|
297
|
+
camera
|
|
298
|
+
});
|
|
299
|
+
assert.equal(sendWhileStopping.accepted, false, 'A frame was accepted while shutdown was in progress.');
|
|
300
|
+
assert.equal(sendWhileStopping.reason, 'native-renderer-stopping',
|
|
301
|
+
'A frame during shutdown did not report the lifecycle rejection.');
|
|
302
|
+
assert.equal(stoppingFactory.children.length, 1,
|
|
303
|
+
'sendOwned spawned a replacement native renderer while the owned child was stopping.');
|
|
304
|
+
assert.equal(stoppingOwner.isRunning(), true,
|
|
305
|
+
'The owner discarded its child before the child confirmed exit.');
|
|
306
|
+
stoppingChild.exit(0, null);
|
|
307
|
+
await stoppingPromise;
|
|
308
|
+
assert.equal(stoppingOwner.isRunning(), false, 'Confirmed child exit did not complete shutdown.');
|
|
309
|
+
|
|
310
|
+
const delayedKillFactory = controlledSpawnFactory({
|
|
311
|
+
pid: 5200,
|
|
312
|
+
autoExitOnShutdown: false,
|
|
313
|
+
autoExitOnKill: false
|
|
314
|
+
});
|
|
315
|
+
const delayedKillOwner = createNativeRendererOwner({
|
|
316
|
+
mode: 'mirror',
|
|
317
|
+
executablePath: process.execPath,
|
|
318
|
+
spawnProcess: delayedKillFactory.spawnControlled,
|
|
319
|
+
readyTimeoutMs: 1_000,
|
|
320
|
+
stopTimeoutMs: 20,
|
|
321
|
+
forceExitTimeoutMs: 1_000
|
|
322
|
+
});
|
|
323
|
+
await delayedKillOwner.start();
|
|
324
|
+
const delayedKillChild = delayedKillFactory.children[0];
|
|
325
|
+
let delayedStopSettled = false;
|
|
326
|
+
const delayedStopPromise = delayedKillOwner.stop().then(
|
|
327
|
+
status => {
|
|
328
|
+
delayedStopSettled = true;
|
|
329
|
+
return status;
|
|
330
|
+
},
|
|
331
|
+
error => {
|
|
332
|
+
delayedStopSettled = true;
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
await new Promise(resolve => setTimeout(resolve, 60));
|
|
337
|
+
assert.ok(delayedKillChild.killSignals.includes('SIGKILL'),
|
|
338
|
+
'The owner did not force-kill a child that ignored graceful shutdown.');
|
|
339
|
+
assert.equal(delayedStopSettled, false,
|
|
340
|
+
'The stop promise settled when SIGKILL was sent instead of waiting for child exit.');
|
|
341
|
+
assert.equal(delayedKillOwner.getStatus().processId, delayedKillChild.pid,
|
|
342
|
+
'The owner cleared the child pid before receiving its exit event.');
|
|
343
|
+
delayedKillChild.exit(1, 'SIGKILL');
|
|
344
|
+
await delayedStopPromise;
|
|
345
|
+
assert.equal(delayedKillOwner.getStatus().processId, 0,
|
|
346
|
+
'The owner retained the child after a confirmed forced exit.');
|
|
347
|
+
|
|
348
|
+
const stuckFactory = controlledSpawnFactory({
|
|
349
|
+
pid: 5300,
|
|
350
|
+
autoExitOnShutdown: false,
|
|
351
|
+
autoExitOnKill: false
|
|
352
|
+
});
|
|
353
|
+
const stuckOwner = createNativeRendererOwner({
|
|
354
|
+
mode: 'mirror',
|
|
355
|
+
executablePath: process.execPath,
|
|
356
|
+
spawnProcess: stuckFactory.spawnControlled,
|
|
357
|
+
readyTimeoutMs: 1_000,
|
|
358
|
+
stopTimeoutMs: 20,
|
|
359
|
+
forceExitTimeoutMs: 20
|
|
360
|
+
});
|
|
361
|
+
await stuckOwner.start();
|
|
362
|
+
const stuckChild = stuckFactory.children[0];
|
|
363
|
+
await assert.rejects(
|
|
364
|
+
stuckOwner.stop(),
|
|
365
|
+
/native-renderer-force-exit-timeout/,
|
|
366
|
+
'A child with no exit confirmation must block shutdown completion.'
|
|
367
|
+
);
|
|
368
|
+
assert.equal(stuckOwner.getStatus().processId, stuckChild.pid,
|
|
369
|
+
'A force-exit timeout discarded the still-running child reference.');
|
|
370
|
+
assert.equal(stuckOwner.isRunning(), true,
|
|
371
|
+
'A force-exit timeout reported a still-running child as stopped.');
|
|
372
|
+
stuckChild.exit(1, 'SIGKILL');
|
|
373
|
+
|
|
374
|
+
assert.ok(statusEvents.some(status => status.ready && status.backend === 'fake-d3d11'),
|
|
375
|
+
'READY status was not projected to the Electron owner.');
|
|
376
|
+
|
|
377
|
+
console.log(JSON.stringify({
|
|
378
|
+
ok: true,
|
|
379
|
+
backend: readyStatus.backend,
|
|
380
|
+
acceptedFrames: cameraResult.acceptedFrames,
|
|
381
|
+
rejectedFrames: cameraResult.rejectedFrames,
|
|
382
|
+
requestCount: fakeChild.requests.length,
|
|
383
|
+
missingHostFailOpen: true,
|
|
384
|
+
sendDuringStopBlocked: true,
|
|
385
|
+
forcedExitConfirmed: true,
|
|
386
|
+
stuckChildRetained: true
|
|
387
|
+
}));
|