@mindexec/cli 0.2.460 → 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.
Files changed (27) hide show
  1. package/electron/main.cjs +147 -3
  2. package/electron/preload.cjs +17 -0
  3. package/electron/recurring-update-owner-smoke.mjs +38 -0
  4. package/electron/recurring-update-owner.cjs +93 -0
  5. package/electron/source-smoke.mjs +32 -0
  6. package/electron/update-feed-resolver.cjs +79 -0
  7. package/electron/update-feed.json +10 -0
  8. package/electron/update-manager-smoke.mjs +146 -0
  9. package/electron/update-manager.cjs +297 -0
  10. package/electron/windows-package-smoke.mjs +3 -0
  11. package/package.json +26 -15
  12. package/scripts/desktop-update-publisher-smoke.mjs +89 -0
  13. package/scripts/desktop-update-worker-smoke.mjs +72 -0
  14. package/scripts/publish-mindexec-desktop-updates.mjs +224 -0
  15. package/wwwroot/_framework/{MindExecution.Core.oqju650dkd.dll → MindExecution.Core.0b8jdcyhj8.dll} +0 -0
  16. package/wwwroot/_framework/{MindExecution.Kernel.7zjugdfmfg.dll → MindExecution.Kernel.hm6hmoblm6.dll} +0 -0
  17. package/wwwroot/_framework/{MindExecution.Plugins.Admin.qln7lkmsnn.dll → MindExecution.Plugins.Admin.oztzw186ns.dll} +0 -0
  18. package/wwwroot/_framework/{MindExecution.Plugins.Business.rd6flxuebm.dll → MindExecution.Plugins.Business.a1e73rkgjv.dll} +0 -0
  19. package/wwwroot/_framework/{MindExecution.Plugins.Concept.0qrgx3epss.dll → MindExecution.Plugins.Concept.rgwn0b8m2o.dll} +0 -0
  20. package/wwwroot/_framework/{MindExecution.Plugins.Directory.4prauy9d1z.dll → MindExecution.Plugins.Directory.2qeqqundtn.dll} +0 -0
  21. package/wwwroot/_framework/{MindExecution.Plugins.PlanMaster.cobfta1p3l.dll → MindExecution.Plugins.PlanMaster.mlf2c4st5u.dll} +0 -0
  22. package/wwwroot/_framework/{MindExecution.Plugins.YouTube.99bahbgkkr.dll → MindExecution.Plugins.YouTube.acobc6tmxc.dll} +0 -0
  23. package/wwwroot/_framework/{MindExecution.Shared.sevaa4rgkp.dll → MindExecution.Shared.6lbogo6eek.dll} +0 -0
  24. package/wwwroot/_framework/{MindExecution.Web.coqh2ccnuk.dll → MindExecution.Web.y97l6vu05i.dll} +0 -0
  25. package/wwwroot/_framework/blazor.boot.json +21 -21
  26. package/wwwroot/service-worker-assets.js +22 -22
  27. 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(launchDesktop)
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,10 @@
1
+ {
2
+ "provider": "generic",
3
+ "baseUrl": "https://mindexec.lovecrdm.workers.dev",
4
+ "channel": "dev",
5
+ "paths": {
6
+ "win32": {
7
+ "x64": "windows/x64"
8
+ }
9
+ }
10
+ }
@@ -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
+ });