11const { 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' ) ;
34const fs = require ( 'node:fs' ) ;
45const http = require ( 'node:http' ) ;
56const 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+
232246function 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
237263function 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
242267function 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+
269308function 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 ( / ^ ( P y t h o n | M i n i c o n d a | M i n i f o r g e | A n a c o n d a | a n a c o n d a | m i n i c o n d a | m i n i f o r g e ) / . 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+
356522function 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
659832function 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
10251210ipcMain . 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+
10311220ipcMain . handle ( 'neuroclaw:restart' , ( ) => {
10321221 log ( 'Restart requested from settings' ) ;
10331222 stopBackend ( ) ;
0 commit comments