@mindexec/cli 0.2.459 → 0.2.461
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 +147 -3
- package/electron/preload.cjs +17 -0
- package/electron/recurring-update-owner-smoke.mjs +38 -0
- package/electron/recurring-update-owner.cjs +93 -0
- package/electron/source-smoke.mjs +32 -0
- package/electron/update-feed-resolver.cjs +79 -0
- package/electron/update-feed.json +10 -0
- package/electron/update-manager-smoke.mjs +146 -0
- package/electron/update-manager.cjs +297 -0
- package/electron/windows-package-smoke.mjs +3 -0
- package/package.json +26 -15
- 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 +89 -0
- package/scripts/desktop-update-worker-smoke.mjs +72 -0
- package/scripts/publish-mindexec-desktop-updates.mjs +224 -0
- package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +49 -7
- package/wwwroot/_framework/{MindExecution.Core.b973f5f64y.dll → MindExecution.Core.0b8jdcyhj8.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Kernel.k4h9fvi9wb.dll → MindExecution.Kernel.hm6hmoblm6.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Admin.u2emae8cb5.dll → MindExecution.Plugins.Admin.oztzw186ns.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Business.cysw4qtyke.dll → MindExecution.Plugins.Business.a1e73rkgjv.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Concept.8iehgvzjio.dll → MindExecution.Plugins.Concept.rgwn0b8m2o.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.Directory.t621fsxq2i.dll → MindExecution.Plugins.Directory.2qeqqundtn.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.PlanMaster.ft631uc7ki.dll → MindExecution.Plugins.PlanMaster.mlf2c4st5u.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Plugins.YouTube.arivgu92h1.dll → MindExecution.Plugins.YouTube.acobc6tmxc.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Shared.wm997st9eb.dll → MindExecution.Shared.6lbogo6eek.dll} +0 -0
- package/wwwroot/_framework/{MindExecution.Web.osdbdrue4h.dll → MindExecution.Web.y97l6vu05i.dll} +0 -0
- package/wwwroot/_framework/blazor.boot.json +21 -21
- package/wwwroot/index.html +1 -1
- package/wwwroot/service-worker-assets.js +24 -24
- package/wwwroot/service-worker.js +1 -1
package/electron/main.cjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { app, BrowserWindow, dialog } = require('electron');
|
|
3
|
+
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
|
|
4
4
|
const { spawn } = require('child_process');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
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 { createProductUpdateManager } = require('./update-manager.cjs');
|
|
11
|
+
const { createRecurringProductUpdateOwner } = require('./recurring-update-owner.cjs');
|
|
10
12
|
|
|
11
13
|
const { resolveDesktopWorkspace } = workspaceState;
|
|
12
14
|
|
|
@@ -14,6 +16,7 @@ const DEFAULT_BRIDGE_PORT = 5147;
|
|
|
14
16
|
const DEFAULT_REMOTE_HUB_PORT = 5198;
|
|
15
17
|
const STARTUP_TIMEOUT_MS = 45_000;
|
|
16
18
|
const HEALTH_POLL_MS = 250;
|
|
19
|
+
const UPDATE_RUNTIME_RELEASE_TIMEOUT_MS = 10_000;
|
|
17
20
|
const DESKTOP_APP_ID = 'com.mindexec.desktop';
|
|
18
21
|
|
|
19
22
|
let mainWindow = null;
|
|
@@ -24,12 +27,24 @@ let activeBridgePort = DEFAULT_BRIDGE_PORT;
|
|
|
24
27
|
let activeRemoteHubPort = DEFAULT_REMOTE_HUB_PORT;
|
|
25
28
|
let activeWorkspace = '';
|
|
26
29
|
let desktopWorkspaceStatePath = '';
|
|
30
|
+
let productUpdates = null;
|
|
31
|
+
let recurringProductUpdates = null;
|
|
32
|
+
let updateInstallSequenceStarted = false;
|
|
33
|
+
let updatePromptPromise = null;
|
|
34
|
+
let updatePromptedVersion = '';
|
|
27
35
|
|
|
28
36
|
function resolveDesktopIconPath() {
|
|
29
37
|
const iconPath = path.resolve(__dirname, '..', 'wwwroot', 'icon-512.png');
|
|
30
38
|
return fs.existsSync(iconPath) ? iconPath : undefined;
|
|
31
39
|
}
|
|
32
40
|
|
|
41
|
+
function resolveDesktopInstallKind() {
|
|
42
|
+
if (!app.isPackaged) return 'development';
|
|
43
|
+
if (process.env.PORTABLE_EXECUTABLE_FILE || process.env.PORTABLE_EXECUTABLE_DIR) return 'portable';
|
|
44
|
+
if (path.basename(path.dirname(process.execPath)).toLowerCase() === 'win-unpacked') return 'unpacked';
|
|
45
|
+
return process.platform === 'win32' ? 'installed' : 'unsupported-platform';
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
function parsePort(value) {
|
|
34
49
|
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
35
50
|
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535 ? parsed : 0;
|
|
@@ -205,6 +220,87 @@ async function prepareBundledRuntime() {
|
|
|
205
220
|
return waitForBundledBridge();
|
|
206
221
|
}
|
|
207
222
|
|
|
223
|
+
function assertTrustedUpdateIpc(event) {
|
|
224
|
+
const senderUrl = event?.senderFrame?.url || event?.sender?.getURL?.() || '';
|
|
225
|
+
let sender;
|
|
226
|
+
try {
|
|
227
|
+
sender = new URL(senderUrl);
|
|
228
|
+
} catch {
|
|
229
|
+
throw new Error('desktop-update-ipc-sender-invalid');
|
|
230
|
+
}
|
|
231
|
+
if (sender.protocol !== 'http:' || sender.hostname !== '127.0.0.1' || sender.port !== String(activeBridgePort)) {
|
|
232
|
+
throw new Error('desktop-update-ipc-sender-untrusted');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function registerUpdateIpc() {
|
|
237
|
+
const handle = (channel, callback) => {
|
|
238
|
+
ipcMain.handle(channel, async (event) => {
|
|
239
|
+
assertTrustedUpdateIpc(event);
|
|
240
|
+
return callback();
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
handle('updates:get-status', () => productUpdates?.getStatus() || { state: 'unavailable' });
|
|
244
|
+
handle('updates:check', () => productUpdates?.check() || Promise.resolve({ state: 'unavailable' }));
|
|
245
|
+
handle('updates:install', () => productUpdates?.install() || Promise.resolve({ state: 'unavailable' }));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function sendUpdateStatus(status) {
|
|
249
|
+
appendDesktopLog(
|
|
250
|
+
`[update] state=${status.state} current=${status.currentVersion || ''} ` +
|
|
251
|
+
`available=${status.availableVersion || ''} progress=${Math.round(status.progress || 0)} ` +
|
|
252
|
+
`kind=${status.installKind || ''}${status.error ? ` error=${status.error}` : ''}`
|
|
253
|
+
);
|
|
254
|
+
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
|
|
255
|
+
mainWindow.webContents.send('updates:status', status);
|
|
256
|
+
}
|
|
257
|
+
if (status.state === 'downloaded') void promptForDownloadedUpdate(status);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function promptForDownloadedUpdate(status = productUpdates?.getStatus()) {
|
|
261
|
+
if (!status?.downloaded || !status.availableVersion || !mainWindow || mainWindow.isDestroyed()) return;
|
|
262
|
+
if (process.env.MINDEXEC_DESKTOP_SMOKE === '1') return;
|
|
263
|
+
if (updatePromptedVersion === status.availableVersion || updatePromptPromise) return updatePromptPromise;
|
|
264
|
+
|
|
265
|
+
updatePromptedVersion = status.availableVersion;
|
|
266
|
+
updatePromptPromise = dialog.showMessageBox(mainWindow, {
|
|
267
|
+
type: 'info',
|
|
268
|
+
title: 'MindExec update ready',
|
|
269
|
+
message: `MindExec ${status.availableVersion} is ready.`,
|
|
270
|
+
detail: 'Restart now to use the new development build. Your project folder and saved data stay unchanged.',
|
|
271
|
+
buttons: ['Restart and update', 'Later'],
|
|
272
|
+
defaultId: 0,
|
|
273
|
+
cancelId: 1,
|
|
274
|
+
noLink: true
|
|
275
|
+
}).then(result => {
|
|
276
|
+
if (result.response === 0) return productUpdates?.install();
|
|
277
|
+
return productUpdates?.getStatus();
|
|
278
|
+
}).catch(error => {
|
|
279
|
+
appendDesktopLog(`[update:prompt-error] ${error?.stack || error}`);
|
|
280
|
+
return productUpdates?.getStatus();
|
|
281
|
+
}).finally(() => {
|
|
282
|
+
updatePromptPromise = null;
|
|
283
|
+
});
|
|
284
|
+
return updatePromptPromise;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function initializeProductUpdates() {
|
|
288
|
+
productUpdates = createProductUpdateManager({
|
|
289
|
+
app,
|
|
290
|
+
installKind: resolveDesktopInstallKind(),
|
|
291
|
+
onStatus: sendUpdateStatus,
|
|
292
|
+
beforeInstall: prepareRuntimeForUpdateInstall,
|
|
293
|
+
onInstallError: recoverRuntimeAfterUpdateInstallError
|
|
294
|
+
});
|
|
295
|
+
recurringProductUpdates = createRecurringProductUpdateOwner({
|
|
296
|
+
manager: productUpdates,
|
|
297
|
+
canRun: () => !isQuitting && !updateInstallSequenceStarted,
|
|
298
|
+
initialDelayMs: 30 * 60_000,
|
|
299
|
+
intervalMs: 30 * 60_000,
|
|
300
|
+
onError: error => appendDesktopLog(`[update:recurring-error] ${error?.stack || error}`)
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
208
304
|
function createMainWindow() {
|
|
209
305
|
const hiddenSmokeMode = process.env.MINDEXEC_DESKTOP_SMOKE === '1';
|
|
210
306
|
const window = new BrowserWindow({
|
|
@@ -218,6 +314,7 @@ function createMainWindow() {
|
|
|
218
314
|
title: 'MindExec',
|
|
219
315
|
icon: resolveDesktopIconPath(),
|
|
220
316
|
webPreferences: {
|
|
317
|
+
preload: path.join(__dirname, 'preload.cjs'),
|
|
221
318
|
contextIsolation: true,
|
|
222
319
|
nodeIntegration: false,
|
|
223
320
|
sandbox: true,
|
|
@@ -297,11 +394,48 @@ async function stopBundledBridge() {
|
|
|
297
394
|
});
|
|
298
395
|
}
|
|
299
396
|
|
|
397
|
+
async function waitForBundledRuntimePortsReleased(timeoutMs = UPDATE_RUNTIME_RELEASE_TIMEOUT_MS) {
|
|
398
|
+
const deadline = Date.now() + timeoutMs;
|
|
399
|
+
while (Date.now() < deadline) {
|
|
400
|
+
const [bridgeReleased, remoteHubReleased] = await Promise.all([
|
|
401
|
+
isPortAvailable(activeBridgePort),
|
|
402
|
+
isPortAvailable(activeRemoteHubPort)
|
|
403
|
+
]);
|
|
404
|
+
if (bridgeReleased && remoteHubReleased) return;
|
|
405
|
+
await new Promise(resolve => setTimeout(resolve, HEALTH_POLL_MS));
|
|
406
|
+
}
|
|
407
|
+
throw new Error(
|
|
408
|
+
`Bundled runtime ports did not release before update: bridge=${activeBridgePort}, remoteHub=${activeRemoteHubPort}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function prepareRuntimeForUpdateInstall() {
|
|
413
|
+
if (updateInstallSequenceStarted) return;
|
|
414
|
+
updateInstallSequenceStarted = true;
|
|
415
|
+
recurringProductUpdates?.stop();
|
|
416
|
+
isQuitting = true;
|
|
417
|
+
appendDesktopLog('[update] stopping bundled runtime before installer launch');
|
|
418
|
+
await stopBundledBridge();
|
|
419
|
+
await waitForBundledRuntimePortsReleased();
|
|
420
|
+
appendDesktopLog('[update] bundled runtime and ports are fully released');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function recoverRuntimeAfterUpdateInstallError(error) {
|
|
424
|
+
appendDesktopLog(`[update:install-recovery] ${error?.stack || error}`);
|
|
425
|
+
isQuitting = false;
|
|
426
|
+
updateInstallSequenceStarted = false;
|
|
427
|
+
if (!bridgeChild || bridgeChild.exitCode !== null) {
|
|
428
|
+
await prepareBundledRuntime();
|
|
429
|
+
}
|
|
430
|
+
recurringProductUpdates?.start();
|
|
431
|
+
}
|
|
432
|
+
|
|
300
433
|
async function launchDesktop() {
|
|
301
434
|
const status = await prepareBundledRuntime();
|
|
302
435
|
const window = createMainWindow();
|
|
303
436
|
const appUrl = `http://127.0.0.1:${activeBridgePort}/mindcanvas`;
|
|
304
437
|
await window.loadURL(appUrl);
|
|
438
|
+
void promptForDownloadedUpdate();
|
|
305
439
|
|
|
306
440
|
if (process.env.MINDEXEC_DESKTOP_SMOKE === '1') {
|
|
307
441
|
writeSmokeResult({
|
|
@@ -312,7 +446,8 @@ async function launchDesktop() {
|
|
|
312
446
|
bridgePort: activeBridgePort,
|
|
313
447
|
remoteHubPort: activeRemoteHubPort,
|
|
314
448
|
workspace: activeWorkspace,
|
|
315
|
-
url: appUrl
|
|
449
|
+
url: appUrl,
|
|
450
|
+
desktopUpdate: productUpdates?.getStatus() || null
|
|
316
451
|
});
|
|
317
452
|
setTimeout(() => app.quit(), 250).unref();
|
|
318
453
|
}
|
|
@@ -345,12 +480,21 @@ if (!hasSingleInstanceLock) {
|
|
|
345
480
|
}
|
|
346
481
|
|
|
347
482
|
event.preventDefault();
|
|
483
|
+
recurringProductUpdates?.stop();
|
|
348
484
|
isQuitting = true;
|
|
349
485
|
stopBundledBridge().finally(() => app.exit(requestedExitCode));
|
|
350
486
|
});
|
|
351
487
|
|
|
352
488
|
app.whenReady()
|
|
353
|
-
.then(
|
|
489
|
+
.then(async () => {
|
|
490
|
+
initializeProductUpdates();
|
|
491
|
+
registerUpdateIpc();
|
|
492
|
+
void productUpdates.checkAtStartup().catch(error => {
|
|
493
|
+
appendDesktopLog(`[update:startup-error] ${error?.stack || error}`);
|
|
494
|
+
});
|
|
495
|
+
await launchDesktop();
|
|
496
|
+
recurringProductUpdates.start();
|
|
497
|
+
})
|
|
354
498
|
.catch((error) => {
|
|
355
499
|
const message = error?.stack || String(error);
|
|
356
500
|
appendDesktopLog(`[desktop:start-error] ${message}`);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
4
|
+
|
|
5
|
+
contextBridge.exposeInMainWorld('mindExecDesktop', {
|
|
6
|
+
updates: {
|
|
7
|
+
getStatus: () => ipcRenderer.invoke('updates:get-status'),
|
|
8
|
+
check: () => ipcRenderer.invoke('updates:check'),
|
|
9
|
+
install: () => ipcRenderer.invoke('updates:install'),
|
|
10
|
+
onStatus: callback => {
|
|
11
|
+
if (typeof callback !== 'function') return () => undefined;
|
|
12
|
+
const listener = (_event, payload) => callback(payload);
|
|
13
|
+
ipcRenderer.on('updates:status', listener);
|
|
14
|
+
return () => ipcRenderer.removeListener('updates:status', listener);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import recurringOwnerModule from './recurring-update-owner.cjs';
|
|
4
|
+
|
|
5
|
+
const { createRecurringProductUpdateOwner } = recurringOwnerModule;
|
|
6
|
+
|
|
7
|
+
test('one recurring owner checks once and never installs without user approval', async () => {
|
|
8
|
+
let checkCalls = 0;
|
|
9
|
+
let installCalls = 0;
|
|
10
|
+
let timerCallback = null;
|
|
11
|
+
const manager = {
|
|
12
|
+
getStatus: () => ({ state: 'downloaded', downloaded: true }),
|
|
13
|
+
check: async () => {
|
|
14
|
+
checkCalls += 1;
|
|
15
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
16
|
+
return { state: 'downloaded', downloaded: true };
|
|
17
|
+
},
|
|
18
|
+
install: async () => { installCalls += 1; }
|
|
19
|
+
};
|
|
20
|
+
const owner = createRecurringProductUpdateOwner({
|
|
21
|
+
manager,
|
|
22
|
+
initialDelayMs: 1_000,
|
|
23
|
+
intervalMs: 1_000,
|
|
24
|
+
setTimer: callback => { timerCallback = callback; return { unref() {} }; },
|
|
25
|
+
clearTimer: () => { timerCallback = null; }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
owner.start();
|
|
29
|
+
assert.equal(owner.inspect().timerCount, 1);
|
|
30
|
+
const [first, second] = await Promise.all([owner.runNow(), owner.runNow()]);
|
|
31
|
+
assert.equal(first.downloaded, true);
|
|
32
|
+
assert.equal(second.downloaded, true);
|
|
33
|
+
assert.equal(checkCalls, 1);
|
|
34
|
+
assert.equal(installCalls, 0);
|
|
35
|
+
assert.ok(timerCallback, 'the single next check must be re-armed');
|
|
36
|
+
owner.stop();
|
|
37
|
+
assert.equal(owner.inspect().timerCount, 0);
|
|
38
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MINIMUM_UPDATE_DELAY_MS = 1_000;
|
|
4
|
+
|
|
5
|
+
function normalizeDelay(value, fallback) {
|
|
6
|
+
const delay = Number(value);
|
|
7
|
+
return Number.isFinite(delay) && delay >= MINIMUM_UPDATE_DELAY_MS
|
|
8
|
+
? Math.round(delay)
|
|
9
|
+
: fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createRecurringProductUpdateOwner({
|
|
13
|
+
manager,
|
|
14
|
+
canRun = () => true,
|
|
15
|
+
initialDelayMs = 30 * 60_000,
|
|
16
|
+
intervalMs = 30 * 60_000,
|
|
17
|
+
setTimer = setTimeout,
|
|
18
|
+
clearTimer = clearTimeout,
|
|
19
|
+
onError = () => undefined
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (!manager || typeof manager.check !== 'function') {
|
|
22
|
+
throw new TypeError('recurring-product-update-manager-required');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const initialDelay = normalizeDelay(initialDelayMs, 30 * 60_000);
|
|
26
|
+
const interval = normalizeDelay(intervalMs, 30 * 60_000);
|
|
27
|
+
let stopped = true;
|
|
28
|
+
let generation = 0;
|
|
29
|
+
let timer = null;
|
|
30
|
+
let running = null;
|
|
31
|
+
|
|
32
|
+
const clearOwnedTimer = () => {
|
|
33
|
+
if (!timer) return;
|
|
34
|
+
clearTimer(timer);
|
|
35
|
+
timer = null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const arm = (delay, ownerGeneration) => {
|
|
39
|
+
if (stopped || ownerGeneration !== generation || !canRun()) return;
|
|
40
|
+
clearOwnedTimer();
|
|
41
|
+
timer = setTimer(() => {
|
|
42
|
+
if (stopped || ownerGeneration !== generation) return;
|
|
43
|
+
timer = null;
|
|
44
|
+
void runCycle(ownerGeneration);
|
|
45
|
+
}, delay);
|
|
46
|
+
timer?.unref?.();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const runCycle = ownerGeneration => {
|
|
50
|
+
if (stopped || ownerGeneration !== generation || !canRun()) {
|
|
51
|
+
return Promise.resolve(manager.getStatus?.() || { state: 'stopped' });
|
|
52
|
+
}
|
|
53
|
+
if (running) return running;
|
|
54
|
+
|
|
55
|
+
const operation = Promise.resolve(manager.check());
|
|
56
|
+
running = operation;
|
|
57
|
+
void operation.catch(error => onError(error)).finally(() => {
|
|
58
|
+
if (running === operation) running = null;
|
|
59
|
+
arm(interval, ownerGeneration);
|
|
60
|
+
});
|
|
61
|
+
return operation;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
start() {
|
|
66
|
+
if (!stopped) return;
|
|
67
|
+
stopped = false;
|
|
68
|
+
generation += 1;
|
|
69
|
+
arm(initialDelay, generation);
|
|
70
|
+
},
|
|
71
|
+
stop() {
|
|
72
|
+
stopped = true;
|
|
73
|
+
generation += 1;
|
|
74
|
+
clearOwnedTimer();
|
|
75
|
+
},
|
|
76
|
+
runNow() {
|
|
77
|
+
clearOwnedTimer();
|
|
78
|
+
return runCycle(generation);
|
|
79
|
+
},
|
|
80
|
+
inspect() {
|
|
81
|
+
return {
|
|
82
|
+
stopped,
|
|
83
|
+
generation,
|
|
84
|
+
timerCount: timer ? 1 : 0,
|
|
85
|
+
running: Boolean(running),
|
|
86
|
+
initialDelayMs: initialDelay,
|
|
87
|
+
intervalMs: interval
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { createRecurringProductUpdateOwner };
|
|
@@ -8,6 +8,9 @@ const electronDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
|
8
8
|
const packageRoot = path.resolve(electronDirectory, '..');
|
|
9
9
|
const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
10
10
|
const mainSource = fs.readFileSync(path.join(electronDirectory, 'main.cjs'), 'utf8');
|
|
11
|
+
const preloadSource = fs.readFileSync(path.join(electronDirectory, 'preload.cjs'), 'utf8');
|
|
12
|
+
const updateManagerSource = fs.readFileSync(path.join(electronDirectory, 'update-manager.cjs'), 'utf8');
|
|
13
|
+
const updateFeed = JSON.parse(fs.readFileSync(path.join(electronDirectory, 'update-feed.json'), 'utf8'));
|
|
11
14
|
const serverSource = fs.readFileSync(path.join(packageRoot, 'server.js'), 'utf8');
|
|
12
15
|
|
|
13
16
|
assert.equal(packageJson.build?.appId, 'com.mindexec.desktop', 'Stable desktop appId is required.');
|
|
@@ -18,6 +21,18 @@ assert.ok(packageJson.files?.includes('electron/'), 'The npm package must includ
|
|
|
18
21
|
assert.ok(packageJson.files?.includes('desktop-workspace-state.cjs'), 'The npm package must include desktop workspace state.');
|
|
19
22
|
assert.match(packageJson.scripts?.['desktop:build:win'] || '', /electron-builder/, 'Windows build script is missing.');
|
|
20
23
|
assert.match(packageJson.scripts?.['test:desktop:win'] || '', /windows-package-smoke/, 'Packaged smoke test is missing.');
|
|
24
|
+
assert.match(packageJson.scripts?.['desktop:publish:update:dev'] || '', /--channel dev[\s\S]*--allow-unsigned[\s\S]*--publish/,
|
|
25
|
+
'The explicit development update publishing command is missing.');
|
|
26
|
+
assert.equal(packageJson.dependencies?.['electron-updater'], '^6.8.9', 'The packaged updater dependency is missing.');
|
|
27
|
+
assert.equal(packageJson.build?.extraMetadata?.desktopUpdateChannel, 'dev', 'Development installers must stay on the dev feed.');
|
|
28
|
+
assert.equal(packageJson.build?.publish?.provider, 'generic', 'Desktop updates must use the generic R2-backed provider.');
|
|
29
|
+
assert.equal(
|
|
30
|
+
packageJson.build?.publish?.url,
|
|
31
|
+
'https://mindexec.lovecrdm.workers.dev/dev/windows/x64',
|
|
32
|
+
'The packaged development update URL changed unexpectedly.'
|
|
33
|
+
);
|
|
34
|
+
assert.equal(updateFeed.baseUrl, 'https://mindexec.lovecrdm.workers.dev', 'MindExec update hostname has a typo.');
|
|
35
|
+
assert.equal(updateFeed.channel, 'dev', 'The packaged update feed must default to the isolated dev channel.');
|
|
21
36
|
|
|
22
37
|
assert.equal(
|
|
23
38
|
packageJson.build?.win?.icon,
|
|
@@ -52,6 +67,7 @@ assert.ok(targets.some((target) => target.target === 'portable'), 'Portable targ
|
|
|
52
67
|
assert.match(mainSource, /contextIsolation:\s*true/, 'Electron renderer isolation must remain enabled.');
|
|
53
68
|
assert.match(mainSource, /nodeIntegration:\s*false/, 'Node integration must remain disabled.');
|
|
54
69
|
assert.match(mainSource, /sandbox:\s*true/, 'Electron renderer sandbox must remain enabled.');
|
|
70
|
+
assert.match(mainSource, /preload:\s*path\.join\(__dirname, 'preload\.cjs'\)/, 'The isolated updater preload is missing.');
|
|
55
71
|
assert.match(mainSource, /runtimeMode:\s*'bundled'/, 'Packaged runtime must report bundled mode.');
|
|
56
72
|
assert.match(mainSource, /spawnBundledBridge/, 'Electron must start its bundled LocalBridge.');
|
|
57
73
|
assert.match(mainSource, /wwwroot.*index\.html/s, 'Electron must verify its bundled frontend.');
|
|
@@ -62,6 +78,21 @@ assert.match(mainSource, /icon:\s*resolveDesktopIconPath\(\)/, 'Electron windows
|
|
|
62
78
|
assert.match(mainSource, /app\.setAppUserModelId\(DESKTOP_APP_ID\)/, 'Windows taskbar identity must use the stable app id.');
|
|
63
79
|
assert.match(mainSource, /resolveDesktopWorkspace/, 'Electron startup must restore the last desktop workspace.');
|
|
64
80
|
assert.match(mainSource, /MINDEXEC_DESKTOP_WORKSPACE_STATE_PATH/, 'Electron must share its workspace state path with LocalBridge.');
|
|
81
|
+
assert.match(mainSource, /resolveDesktopInstallKind/, 'Installed, portable, and unpacked update behavior must remain separate.');
|
|
82
|
+
assert.match(mainSource, /createProductUpdateManager/, 'The Electron update manager is not wired into the app.');
|
|
83
|
+
assert.match(mainSource, /updates:get-status/, 'The updater status IPC contract is missing.');
|
|
84
|
+
assert.match(mainSource, /updates:check/, 'The manual updater check IPC contract is missing.');
|
|
85
|
+
assert.match(mainSource, /updates:install/, 'The updater install IPC contract is missing.');
|
|
86
|
+
assert.match(mainSource, /await stopBundledBridge\(\)[\s\S]{0,180}await waitForBundledRuntimePortsReleased\(\)/,
|
|
87
|
+
'The Bridge and RemoteHub ports must reach zero before the installer starts.');
|
|
88
|
+
assert.match(mainSource, /Restart and update/, 'A downloaded update must ask before restarting active work.');
|
|
89
|
+
assert.match(preloadSource, /mindExecDesktop/, 'The updater preload namespace changed unexpectedly.');
|
|
90
|
+
assert.match(preloadSource, /updates:\s*\{/, 'The updater preload API is missing.');
|
|
91
|
+
assert.match(updateManagerSource, /autoDownload\s*=\s*false/, 'Updates must not download through an unowned implicit path.');
|
|
92
|
+
assert.match(updateManagerSource, /autoInstallOnAppQuit\s*=\s*false/, 'Updates must not restart without the explicit prompt path.');
|
|
93
|
+
assert.match(updateManagerSource, /quitAndInstall\(true, true\)/, 'Downloaded updates must relaunch silently after approval.');
|
|
94
|
+
assert.match(updateManagerSource, /desktop-updates-require-installed-windows-app/,
|
|
95
|
+
'Portable and unpacked builds must fail closed instead of replacing themselves.');
|
|
65
96
|
assert.match(serverSource, /saveDesktopWorkspaceState\(DESKTOP_WORKSPACE_STATE_PATH, resolvedPath\)/,
|
|
66
97
|
'A successful desktop workspace switch must be saved for the next launch.');
|
|
67
98
|
|
|
@@ -71,5 +102,6 @@ console.log(JSON.stringify({
|
|
|
71
102
|
version: packageJson.version,
|
|
72
103
|
runtimeMode: 'bundled',
|
|
73
104
|
icon: packageJson.build.win.icon,
|
|
105
|
+
updateFeed: `${updateFeed.baseUrl}/${updateFeed.channel}/windows/x64`,
|
|
74
106
|
targets: targets.map((target) => `${target.target}:${target.arch.join(',')}`)
|
|
75
107
|
}));
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const LOCAL_HTTP_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
4
|
+
const CHANNEL_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
5
|
+
const SEGMENT_PATTERN = /^[a-z0-9][a-z0-9/-]{0,127}$/;
|
|
6
|
+
|
|
7
|
+
function normalizeUrl(value) {
|
|
8
|
+
return String(value || '').trim().replace(/\/+$/, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validateFeedUrl(value) {
|
|
12
|
+
const normalized = normalizeUrl(value);
|
|
13
|
+
if (!normalized) return { url: '', error: 'desktop-update-feed-not-configured' };
|
|
14
|
+
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(normalized);
|
|
18
|
+
} catch {
|
|
19
|
+
return { url: '', error: 'desktop-update-feed-invalid' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const localHttp = parsed.protocol === 'http:' && LOCAL_HTTP_HOSTS.has(parsed.hostname);
|
|
23
|
+
if (parsed.protocol !== 'https:' && !localHttp) {
|
|
24
|
+
return { url: '', error: 'desktop-update-feed-insecure' };
|
|
25
|
+
}
|
|
26
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
27
|
+
return { url: '', error: 'desktop-update-feed-invalid' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { url: normalizeUrl(parsed.toString()), error: '' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveDesktopUpdateFeed({
|
|
34
|
+
config = {},
|
|
35
|
+
overrideUrl = '',
|
|
36
|
+
platform = process.platform,
|
|
37
|
+
arch = process.arch
|
|
38
|
+
} = {}) {
|
|
39
|
+
const exactOverride = validateFeedUrl(overrideUrl);
|
|
40
|
+
if (String(overrideUrl || '').trim()) {
|
|
41
|
+
return {
|
|
42
|
+
...exactOverride,
|
|
43
|
+
configured: Boolean(exactOverride.url),
|
|
44
|
+
platform,
|
|
45
|
+
arch,
|
|
46
|
+
channel: 'override'
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const channel = String(config?.channel || 'dev').trim().toLowerCase();
|
|
51
|
+
const pathSegment = String(config?.paths?.[platform]?.[arch] || '')
|
|
52
|
+
.trim()
|
|
53
|
+
.replace(/^\/+|\/+$/g, '');
|
|
54
|
+
if (!CHANNEL_PATTERN.test(channel)) {
|
|
55
|
+
return { url: '', configured: false, error: 'desktop-update-channel-invalid', platform, arch, channel };
|
|
56
|
+
}
|
|
57
|
+
if (!SEGMENT_PATTERN.test(pathSegment)) {
|
|
58
|
+
return { url: '', configured: false, error: 'desktop-update-platform-unsupported', platform, arch, channel };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const base = validateFeedUrl(config?.baseUrl);
|
|
62
|
+
if (!base.url) {
|
|
63
|
+
return { ...base, configured: false, platform, arch, channel };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
url: `${base.url}/${channel}/${pathSegment}`,
|
|
68
|
+
configured: true,
|
|
69
|
+
error: '',
|
|
70
|
+
platform,
|
|
71
|
+
arch,
|
|
72
|
+
channel
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
resolveDesktopUpdateFeed,
|
|
78
|
+
validateFeedUrl
|
|
79
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import updateManagerModule from './update-manager.cjs';
|
|
5
|
+
import updateFeedResolverModule from './update-feed-resolver.cjs';
|
|
6
|
+
|
|
7
|
+
const { createProductUpdateManager } = updateManagerModule;
|
|
8
|
+
const { resolveDesktopUpdateFeed } = updateFeedResolverModule;
|
|
9
|
+
|
|
10
|
+
const feedConfig = {
|
|
11
|
+
baseUrl: 'https://updates.example',
|
|
12
|
+
channel: 'dev',
|
|
13
|
+
paths: { win32: { x64: 'windows/x64' } }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
class FakeUpdater extends EventEmitter {
|
|
17
|
+
checkCalls = 0;
|
|
18
|
+
downloadCalls = 0;
|
|
19
|
+
feed = null;
|
|
20
|
+
installStarted = false;
|
|
21
|
+
|
|
22
|
+
setFeedURL(feed) { this.feed = feed; }
|
|
23
|
+
|
|
24
|
+
async checkForUpdates() {
|
|
25
|
+
this.checkCalls += 1;
|
|
26
|
+
this.emit('checking-for-update');
|
|
27
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
28
|
+
this.emit('update-available', { version: '1.1.0' });
|
|
29
|
+
return { updateInfo: { version: '1.1.0' } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async downloadUpdate() {
|
|
33
|
+
this.downloadCalls += 1;
|
|
34
|
+
this.emit('download-progress', { percent: 42 });
|
|
35
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
36
|
+
this.emit('update-downloaded', { version: '1.1.0' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
quitAndInstall() { this.installStarted = true; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createManager(updater, hooks = {}) {
|
|
43
|
+
return createProductUpdateManager({
|
|
44
|
+
app: {
|
|
45
|
+
isPackaged: true,
|
|
46
|
+
getVersion: () => '1.0.0',
|
|
47
|
+
getAppPath: () => process.cwd()
|
|
48
|
+
},
|
|
49
|
+
updater,
|
|
50
|
+
platform: 'win32',
|
|
51
|
+
arch: 'x64',
|
|
52
|
+
installKind: 'installed',
|
|
53
|
+
updateFeedConfig: feedConfig,
|
|
54
|
+
overrideUrl: '',
|
|
55
|
+
...hooks
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('feed resolver pins Windows x64 to the exact development path and rejects insecure servers', () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
resolveDesktopUpdateFeed({ config: feedConfig, platform: 'win32', arch: 'x64' }).url,
|
|
62
|
+
'https://updates.example/dev/windows/x64'
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
resolveDesktopUpdateFeed({ config: { ...feedConfig, baseUrl: 'http://updates.example' }, platform: 'win32', arch: 'x64' }).error,
|
|
66
|
+
'desktop-update-feed-insecure'
|
|
67
|
+
);
|
|
68
|
+
assert.equal(
|
|
69
|
+
resolveDesktopUpdateFeed({ config: feedConfig, platform: 'win32', arch: 'arm64' }).error,
|
|
70
|
+
'desktop-update-platform-unsupported'
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('concurrent checks share one version request and one download', async () => {
|
|
75
|
+
const updater = new FakeUpdater();
|
|
76
|
+
const manager = createManager(updater);
|
|
77
|
+
const [first, second] = await Promise.all([manager.check(), manager.check()]);
|
|
78
|
+
assert.equal(first.state, 'downloaded');
|
|
79
|
+
assert.equal(second.state, 'downloaded');
|
|
80
|
+
assert.equal(updater.checkCalls, 1);
|
|
81
|
+
assert.equal(updater.downloadCalls, 1);
|
|
82
|
+
assert.deepEqual(updater.feed, { provider: 'generic', url: 'https://updates.example/dev/windows/x64' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('portable and unpacked executables never call electron-updater', async () => {
|
|
86
|
+
for (const installKind of ['portable', 'unpacked']) {
|
|
87
|
+
const updater = new FakeUpdater();
|
|
88
|
+
const manager = createManager(updater, { installKind });
|
|
89
|
+
const result = await manager.check();
|
|
90
|
+
assert.equal(result.state, 'unsupported');
|
|
91
|
+
assert.equal(result.updateSupported, false);
|
|
92
|
+
assert.equal(updater.checkCalls, 0);
|
|
93
|
+
assert.equal(updater.downloadCalls, 0);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('install starts only after the owned runtime and ports are released', async () => {
|
|
98
|
+
const order = [];
|
|
99
|
+
const updater = new FakeUpdater();
|
|
100
|
+
updater.quitAndInstall = () => { order.push('installer'); updater.installStarted = true; };
|
|
101
|
+
const manager = createManager(updater, {
|
|
102
|
+
beforeInstall: async () => { order.push('runtime-and-ports-zero'); }
|
|
103
|
+
});
|
|
104
|
+
await manager.check();
|
|
105
|
+
await manager.install();
|
|
106
|
+
assert.deepEqual(order, ['runtime-and-ports-zero', 'installer']);
|
|
107
|
+
assert.equal(updater.installStarted, true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a preparation or installer failure restores runtime ownership and allows retry', async () => {
|
|
111
|
+
const order = [];
|
|
112
|
+
const updater = new FakeUpdater();
|
|
113
|
+
let prepareAttempts = 0;
|
|
114
|
+
const manager = createManager(updater, {
|
|
115
|
+
beforeInstall: async () => {
|
|
116
|
+
prepareAttempts += 1;
|
|
117
|
+
order.push(`prepare-${prepareAttempts}`);
|
|
118
|
+
if (prepareAttempts === 1) throw new Error('runtime-release-failed');
|
|
119
|
+
},
|
|
120
|
+
onInstallError: async () => { order.push('runtime-restored'); }
|
|
121
|
+
});
|
|
122
|
+
await manager.check();
|
|
123
|
+
assert.equal((await manager.install()).state, 'error');
|
|
124
|
+
assert.equal((await manager.install()).state, 'installing');
|
|
125
|
+
assert.deepEqual(order, ['prepare-1', 'runtime-restored', 'prepare-2']);
|
|
126
|
+
assert.equal(updater.installStarted, true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a slow update server never holds startup beyond its small budget', async () => {
|
|
130
|
+
const updater = new FakeUpdater();
|
|
131
|
+
updater.checkForUpdates = async () => {
|
|
132
|
+
updater.checkCalls += 1;
|
|
133
|
+
updater.emit('checking-for-update');
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
135
|
+
updater.emit('update-not-available');
|
|
136
|
+
return { updateInfo: { version: '1.0.0' } };
|
|
137
|
+
};
|
|
138
|
+
const manager = createManager(updater, { startupCheckTimeoutMs: 20 });
|
|
139
|
+
const startedAt = Date.now();
|
|
140
|
+
const status = await manager.checkAtStartup();
|
|
141
|
+
assert.ok(Date.now() - startedAt < 70, 'startup must continue while a slow check finishes in the background');
|
|
142
|
+
assert.equal(status.startupCheck, 'timed-out');
|
|
143
|
+
await new Promise(resolve => setTimeout(resolve, 90));
|
|
144
|
+
assert.equal(manager.getStatus().state, 'current');
|
|
145
|
+
assert.equal(manager.getStatus().startupCheck, 'complete');
|
|
146
|
+
});
|