Skip to content

Commit 45c7510

Browse files
committed
fix(desktop): use bundled runtime by default
1 parent 9c19afb commit 45c7510

7 files changed

Lines changed: 581 additions & 52 deletions

File tree

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.sh text eol=lf

core/web/static/index.html

Lines changed: 220 additions & 26 deletions
Large diffs are not rendered by default.

desktop/main.js

Lines changed: 207 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { app, BrowserWindow, Menu, dialog, ipcMain, shell, clipboard } = require('electron');
2-
const { spawn } = require('node:child_process');
2+
const { spawn, spawnSync } = require('node:child_process');
3+
const crypto = require('node:crypto');
34
const fs = require('node:fs');
45
const http = require('node:http');
56
const os = require('node:os');
@@ -229,14 +230,38 @@ function userRuntimeRoot() {
229230
return path.join(app.getPath('userData'), 'bundled-runtime', BUNDLED_RUNTIME_VERSION);
230231
}
231232

233+
function bundledPythonCandidates(runtimeRoot = userRuntimeRoot()) {
234+
if (process.platform === 'win32') {
235+
return [
236+
path.join(runtimeRoot, 'python', 'python.exe'),
237+
path.join(runtimeRoot, 'python', 'Scripts', 'python.exe'),
238+
];
239+
}
240+
return [
241+
path.join(runtimeRoot, 'python', 'bin', 'python'),
242+
path.join(runtimeRoot, 'python', 'python'),
243+
];
244+
}
245+
232246
function bundledPythonExe(runtimeRoot = userRuntimeRoot()) {
233-
if (process.platform === 'win32') return path.join(runtimeRoot, 'python', 'python.exe');
234-
return path.join(runtimeRoot, 'python', 'bin', 'python');
247+
return firstExistingPath(bundledPythonCandidates(runtimeRoot));
248+
}
249+
250+
function bundledCondaUnpackCandidates(runtimeRoot = userRuntimeRoot()) {
251+
if (process.platform === 'win32') {
252+
return [
253+
path.join(runtimeRoot, 'python', 'Scripts', 'conda-unpack.exe'),
254+
path.join(runtimeRoot, 'python', 'conda-unpack.exe'),
255+
];
256+
}
257+
return [
258+
path.join(runtimeRoot, 'python', 'bin', 'conda-unpack'),
259+
path.join(runtimeRoot, 'python', 'conda-unpack'),
260+
];
235261
}
236262

237263
function bundledCondaUnpackExe(runtimeRoot = userRuntimeRoot()) {
238-
if (process.platform === 'win32') return path.join(runtimeRoot, 'python', 'Scripts', 'conda-unpack.exe');
239-
return path.join(runtimeRoot, 'python', 'bin', 'conda-unpack');
264+
return firstExistingPath(bundledCondaUnpackCandidates(runtimeRoot));
240265
}
241266

242267
function ensureExecutableIfExists(filePath) {
@@ -266,7 +291,27 @@ function bundledRuntimeSourceExists() {
266291
&& fs.existsSync(path.join(sourceRoot, 'backend', 'core', 'agent', 'main.py'));
267292
}
268293

294+
function bundledRuntimeMarkerValue() {
295+
const manifestPath = path.join(packagedRuntimeSourceRoot(), 'runtime-manifest.json');
296+
try {
297+
const digest = crypto
298+
.createHash('sha256')
299+
.update(fs.readFileSync(manifestPath))
300+
.digest('hex')
301+
.slice(0, 16);
302+
return `${BUNDLED_RUNTIME_VERSION}:${digest}`;
303+
} catch (_err) {
304+
return BUNDLED_RUNTIME_VERSION;
305+
}
306+
}
307+
269308
function bundledRuntimeReady(runtimeRoot = userRuntimeRoot()) {
309+
if (process.platform === 'win32' && fs.existsSync(path.join(runtimeRoot, 'python', 'pyvenv.cfg'))) {
310+
return false;
311+
}
312+
if (process.platform === 'win32' && !fs.existsSync(path.join(runtimeRoot, 'python', 'python.exe'))) {
313+
return false;
314+
}
270315
return fs.existsSync(bundledPythonExe(runtimeRoot))
271316
&& fs.existsSync(path.join(bundledBackendRoot(runtimeRoot), 'core', 'agent', 'main.py'));
272317
}
@@ -306,14 +351,15 @@ function ensureBundledRuntime() {
306351
const sourceRoot = packagedRuntimeSourceRoot();
307352
const runtimeRoot = userRuntimeRoot();
308353
const marker = path.join(runtimeRoot, '.runtime-version');
354+
const expectedMarker = bundledRuntimeMarkerValue();
309355
const currentVersion = fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').trim() : '';
310-
if (!bundledRuntimeReady(runtimeRoot) || currentVersion !== BUNDLED_RUNTIME_VERSION) {
356+
if (!bundledRuntimeReady(runtimeRoot) || currentVersion !== expectedMarker) {
311357
log(`Preparing bundled runtime ${BUNDLED_RUNTIME_VERSION} at ${runtimeRoot}`);
312358
fs.rmSync(runtimeRoot, { recursive: true, force: true });
313359
fs.mkdirSync(runtimeRoot, { recursive: true });
314360
copyDirectoryFresh(path.join(sourceRoot, 'python'), path.join(runtimeRoot, 'python'));
315361
copyDirectoryFresh(path.join(sourceRoot, 'backend'), path.join(runtimeRoot, 'backend'));
316-
fs.writeFileSync(marker, BUNDLED_RUNTIME_VERSION, 'utf8');
362+
fs.writeFileSync(marker, expectedMarker, 'utf8');
317363
}
318364
ensureBundledRuntimeExecutables(runtimeRoot);
319365
runBundledCondaUnpack(runtimeRoot);
@@ -353,16 +399,136 @@ function defaultLlmBaseUrl() {
353399
return process.env.NEUROCLAW_LLM_BASE_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
354400
}
355401

402+
function pushUniquePath(out, seen, filePath) {
403+
const value = String(filePath || '').trim().replace(/^"|"$/g, '');
404+
if (!value || !fs.existsSync(value)) return;
405+
const resolved = path.resolve(value);
406+
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
407+
if (seen.has(key)) return;
408+
seen.add(key);
409+
out.push(resolved);
410+
}
411+
412+
function commandOutputLines(command, args = []) {
413+
const proc = spawnSync(command, args, {
414+
windowsHide: true,
415+
encoding: 'utf8',
416+
timeout: 3000,
417+
});
418+
if (proc.error || proc.status !== 0) return [];
419+
return `${proc.stdout || ''}\n${proc.stderr || ''}`
420+
.split(/\r?\n/)
421+
.map(line => line.trim())
422+
.filter(Boolean);
423+
}
424+
425+
function pythonVersion(pythonPath) {
426+
const proc = spawnSync(pythonPath, ['--version'], {
427+
windowsHide: true,
428+
encoding: 'utf8',
429+
timeout: 3000,
430+
});
431+
if (proc.error || proc.status !== 0) return '';
432+
return `${proc.stdout || proc.stderr || ''}`.trim();
433+
}
434+
435+
function detectLocalPythons() {
436+
const candidates = [];
437+
const seen = new Set();
438+
const home = os.homedir();
439+
440+
const pathEntries = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
441+
for (const entry of pathEntries) {
442+
if (process.platform === 'win32') {
443+
pushUniquePath(candidates, seen, path.join(entry, 'python.exe'));
444+
pushUniquePath(candidates, seen, path.join(entry, 'python3.exe'));
445+
} else {
446+
pushUniquePath(candidates, seen, path.join(entry, 'python3'));
447+
pushUniquePath(candidates, seen, path.join(entry, 'python'));
448+
}
449+
}
450+
451+
if (process.platform === 'win32') {
452+
for (const line of [...commandOutputLines('where', ['python']), ...commandOutputLines('where', ['python3'])]) {
453+
pushUniquePath(candidates, seen, line);
454+
}
455+
for (const root of [
456+
path.join(home, 'AppData', 'Local', 'Programs', 'Python'),
457+
'C:\\Program Files',
458+
'C:\\Program Files (x86)',
459+
home,
460+
]) {
461+
try {
462+
if (!fs.existsSync(root)) continue;
463+
for (const child of fs.readdirSync(root)) {
464+
if (/^(Python|Miniconda|Miniforge|Anaconda|anaconda|miniconda|miniforge)/.test(child)) {
465+
pushUniquePath(candidates, seen, path.join(root, child, 'python.exe'));
466+
pushUniquePath(candidates, seen, path.join(root, child, 'Scripts', 'python.exe'));
467+
pushUniquePath(candidates, seen, path.join(root, child, 'envs', 'neuroclaw', 'python.exe'));
468+
}
469+
}
470+
} catch (_err) {}
471+
}
472+
} else {
473+
for (const line of commandOutputLines('which', ['-a', 'python3', 'python'])) {
474+
pushUniquePath(candidates, seen, line);
475+
}
476+
for (const candidate of [
477+
'/usr/bin/python3',
478+
'/opt/homebrew/bin/python3',
479+
'/usr/local/bin/python3',
480+
path.join(home, 'miniforge3', 'bin', 'python'),
481+
path.join(home, 'miniconda3', 'bin', 'python'),
482+
path.join(home, 'anaconda3', 'bin', 'python'),
483+
path.join(home, 'miniforge3', 'envs', 'neuroclaw', 'bin', 'python'),
484+
path.join(home, 'miniconda3', 'envs', 'neuroclaw', 'bin', 'python'),
485+
path.join(home, 'anaconda3', 'envs', 'neuroclaw', 'bin', 'python'),
486+
]) {
487+
pushUniquePath(candidates, seen, candidate);
488+
}
489+
}
490+
491+
return candidates.map(candidate => {
492+
const version = pythonVersion(candidate);
493+
return {
494+
path: candidate,
495+
version,
496+
label: `${version || 'Python'} - ${candidate}`,
497+
};
498+
});
499+
}
500+
501+
function normalizePackagedRuntimeConfig(config) {
502+
if (!app.isPackaged) return config;
503+
const localPythonExe = String(config.localPythonExe || '').trim().replace(/^"|"$/g, '');
504+
if (config.runtimeMode === 'python' && localPythonExe && fs.existsSync(localPythonExe)) {
505+
return {
506+
...config,
507+
runtimeMode: 'python',
508+
pythonExe: localPythonExe,
509+
condaExe: '',
510+
repoRoot: bundledBackendRoot(),
511+
};
512+
}
513+
return {
514+
...config,
515+
runtimeMode: 'bundled',
516+
pythonExe: bundledPythonExe(),
517+
condaExe: '',
518+
repoRoot: bundledBackendRoot(),
519+
};
520+
}
521+
356522
function defaultConfig() {
357523
const home = os.homedir();
358-
const hasBundledRuntime = app.isPackaged && bundledRuntimeSourceExists();
359524
return {
360525
host: '127.0.0.1',
361526
port: 7080,
362-
runtimeMode: process.env.NEUROCLAW_RUNTIME_MODE || (hasBundledRuntime ? 'bundled' : 'conda'),
363-
pythonExe: process.env.NEUROCLAW_PYTHON_EXE || defaultPythonExe(home),
364-
condaExe: process.env.NEUROCLAW_CONDA_EXE || defaultCondaExe(home),
527+
runtimeMode: app.isPackaged ? 'bundled' : (process.env.NEUROCLAW_RUNTIME_MODE || 'conda'),
528+
pythonExe: app.isPackaged ? bundledPythonExe() : (process.env.NEUROCLAW_PYTHON_EXE || defaultPythonExe(home)),
529+
condaExe: app.isPackaged ? '' : (process.env.NEUROCLAW_CONDA_EXE || defaultCondaExe(home)),
365530
condaEnv: process.env.NEUROCLAW_CONDA_ENV || 'neuroclaw',
531+
localPythonExe: process.env.NEUROCLAW_LOCAL_PYTHON_EXE || '',
366532
fslDir: process.env.FSLDIR || '',
367533
language: process.env.NEUROCLAW_LANGUAGE || 'English',
368534
proxyUrl: process.env.NEUROCLAW_PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || 'http://127.0.0.1:7897',
@@ -371,7 +537,7 @@ function defaultConfig() {
371537
llmBaseUrl: defaultLlmBaseUrl(),
372538
llmApiKey: process.env.NEUROCLAW_LLM_API_KEY || '',
373539
llmApiKeyEnv: process.env.NEUROCLAW_LLM_API_KEY_ENV || 'OPENAI_API_KEY',
374-
repoRoot: process.env.NEUROCLAW_REPO_ROOT || (hasBundledRuntime ? bundledBackendRoot() : (app.isPackaged ? path.join(home, 'Documents', 'Code', 'NeuroClaw') : repoRoot())),
540+
repoRoot: app.isPackaged ? bundledBackendRoot() : (process.env.NEUROCLAW_REPO_ROOT || repoRoot()),
375541
};
376542
}
377543

@@ -393,9 +559,9 @@ function loadConfig() {
393559
const defaults = defaultConfig();
394560
try {
395561
const raw = fs.readFileSync(userConfigPath(), 'utf8');
396-
return normalizeConfig({ ...defaults, ...JSON.parse(raw) });
562+
return normalizePackagedRuntimeConfig(normalizeConfig({ ...defaults, ...JSON.parse(raw) }));
397563
} catch (_err) {
398-
return normalizeConfig(defaults);
564+
return normalizePackagedRuntimeConfig(normalizeConfig(defaults));
399565
}
400566
}
401567

@@ -409,6 +575,7 @@ function saveConfig(nextConfig) {
409575
'pythonExe',
410576
'condaExe',
411577
'condaEnv',
578+
'localPythonExe',
412579
'repoRoot',
413580
'fslDir',
414581
'language',
@@ -426,7 +593,7 @@ function saveConfig(nextConfig) {
426593
}
427594
}
428595
fs.mkdirSync(path.dirname(userConfigPath()), { recursive: true });
429-
fs.writeFileSync(userConfigPath(), JSON.stringify({ ...defaults, ...clean }, null, 2), 'utf8');
596+
fs.writeFileSync(userConfigPath(), JSON.stringify(normalizePackagedRuntimeConfig({ ...defaults, ...clean }), null, 2), 'utf8');
430597
return loadConfig();
431598
}
432599

@@ -488,9 +655,15 @@ function applyDesktopLlmConfig(config) {
488655
const envPath = path.join(config.repoRoot, 'neuroclaw_environment.json');
489656
const envConfig = readJsonObject(envPath);
490657

491-
envConfig.setup_type = envConfig.setup_type || (config.runtimeMode === 'bundled' ? 'bundled' : 'desktop');
492-
envConfig.python_path = envConfig.python_path || (config.runtimeMode === 'bundled' ? 'bundled' : config.pythonExe || '');
493-
envConfig.conda_env = envConfig.conda_env || (config.runtimeMode === 'conda' ? config.condaEnv || '' : '');
658+
if (config.runtimeMode === 'bundled') {
659+
envConfig.setup_type = 'bundled';
660+
envConfig.python_path = 'bundled';
661+
envConfig.conda_env = '';
662+
} else {
663+
envConfig.setup_type = envConfig.setup_type || 'desktop';
664+
envConfig.python_path = envConfig.python_path || config.pythonExe || '';
665+
envConfig.conda_env = envConfig.conda_env || (config.runtimeMode === 'conda' ? config.condaEnv || '' : '');
666+
}
494667
envConfig.cuda = envConfig.cuda && typeof envConfig.cuda === 'object' ? envConfig.cuda : { device: 'cpu' };
495668
envConfig.toolchain = envConfig.toolchain && typeof envConfig.toolchain === 'object' ? envConfig.toolchain : {};
496669
envConfig.compression_mode = envConfig.compression_mode || 'stub';
@@ -657,6 +830,10 @@ function validateConfig(config) {
657830
}
658831

659832
function resolveRuntimeConfig(config) {
833+
config = normalizePackagedRuntimeConfig(config);
834+
if (config.runtimeMode === 'python' && config.localPythonExe) {
835+
config = { ...config, pythonExe: config.localPythonExe };
836+
}
660837
if (config.runtimeMode !== 'bundled') return config;
661838
const runtime = ensureBundledRuntime();
662839
if (!runtime) {
@@ -894,6 +1071,12 @@ function sendMenuAction(action) {
8941071
const target = BrowserWindow.getFocusedWindow() || mainWindow;
8951072
if (target && !target.isDestroyed()) {
8961073
target.webContents.send('neuroclaw:menu-action', action);
1074+
target.webContents.executeJavaScript(
1075+
`window.dispatchEvent(new CustomEvent('neuroclaw:menu-action', { detail: ${JSON.stringify(action)} }))`,
1076+
true,
1077+
).catch((err) => {
1078+
log(`Menu action fallback failed for "${action}": ${err && err.message ? err.message : err}`);
1079+
});
8971080
}
8981081
}
8991082

@@ -1020,6 +1203,8 @@ ipcMain.handle('neuroclaw:get-config', () => ({
10201203
config: loadConfig(),
10211204
configPath: userConfigPath(),
10221205
logsPath: path.join(app.getPath('userData'), 'logs'),
1206+
isPackaged: app.isPackaged,
1207+
platform: process.platform,
10231208
}));
10241209

10251210
ipcMain.handle('neuroclaw:save-config', (_event, config) => ({
@@ -1028,6 +1213,10 @@ ipcMain.handle('neuroclaw:save-config', (_event, config) => ({
10281213
restartRequired: true,
10291214
}));
10301215

1216+
ipcMain.handle('neuroclaw:detect-local-pythons', () => ({
1217+
candidates: detectLocalPythons(),
1218+
}));
1219+
10311220
ipcMain.handle('neuroclaw:restart', () => {
10321221
log('Restart requested from settings');
10331222
stopBackend();

desktop/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
"dev": "electron .",
1111
"start": "electron .",
1212
"pack": "electron-builder --dir",
13+
"patch:portable:win": "node scripts/patch-portable-progress.js",
1314
"prepare:runtime:win": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/prepare-bundled-runtime.ps1",
1415
"prepare:runtime:mac": "bash scripts/prepare-bundled-runtime-mac.sh",
15-
"dist:win": "electron-builder --win nsis portable",
16+
"dist:win": "npm run prepare:runtime:win && npm run patch:portable:win && electron-builder --win nsis portable zip",
17+
"dist:win:skip-runtime": "npm run patch:portable:win && electron-builder --win nsis portable zip",
1618
"dist:mac": "npm run prepare:runtime:mac && electron-builder --mac dmg zip",
1719
"dist:mac:skip-runtime": "electron-builder --mac dmg zip",
1820
"dist:mac:arm64": "npm run prepare:runtime:mac && electron-builder --mac dmg zip --arm64",
@@ -49,7 +51,8 @@
4951
"win": {
5052
"target": [
5153
"nsis",
52-
"portable"
54+
"portable",
55+
"zip"
5356
]
5457
},
5558
"mac": {

desktop/preload.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
const { contextBridge, ipcRenderer } = require('electron');
22

3+
const DESKTOP_VERSION = '0.2.0';
4+
35
contextBridge.exposeInMainWorld('neuroclawDesktop', {
4-
version: '0.2.0',
6+
version: DESKTOP_VERSION,
57
platform: process.platform,
68
onMenuAction: (callback) => {
79
if (typeof callback !== 'function') return () => {};
@@ -11,5 +13,6 @@ contextBridge.exposeInMainWorld('neuroclawDesktop', {
1113
},
1214
getConfig: () => ipcRenderer.invoke('neuroclaw:get-config'),
1315
saveConfig: (config) => ipcRenderer.invoke('neuroclaw:save-config', config),
16+
detectLocalPythons: () => ipcRenderer.invoke('neuroclaw:detect-local-pythons'),
1417
restart: () => ipcRenderer.invoke('neuroclaw:restart'),
1518
});

0 commit comments

Comments
 (0)