diff --git a/src/main/app.ts b/src/main/app.ts index 5705bf13..93c5381b 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -36,6 +36,10 @@ export interface IApplication { saveState: (service: IStatefulService, data: JSONValue) => Promise; getPythonEnvironment(): Promise; + + setCondaRootPath(condaRootPath: string): void; + + getCondaRootPath(): Promise; } /** @@ -127,7 +131,8 @@ export class JupyterApplication implements IApplication, IStatefulService { this._applicationState = { checkForUpdatesAutomatically: true, - pythonPath: '' + pythonPath: '', + condaRootPath: '' }; this.registerStatefulService(this).then( @@ -168,6 +173,18 @@ export class JupyterApplication implements IApplication, IStatefulService { }); } + setCondaRootPath(condaRootPath: string): void { + this._applicationState.condaRootPath = condaRootPath; + } + + getCondaRootPath(): Promise { + return new Promise((resolve, _reject) => { + this._appState.then((state: JSONObject) => { + resolve(this._applicationState.condaRootPath); + }); + }); + } + registerStatefulService(service: IStatefulService): Promise { this._services.push(service); @@ -630,6 +647,7 @@ export namespace JupyterApplication { export interface IState extends JSONObject { checkForUpdatesAutomatically?: boolean; pythonPath?: string; + condaRootPath?: string; } } diff --git a/src/main/env_info.py b/src/main/env_info.py index a7c0f66b..db3b792d 100644 --- a/src/main/env_info.py +++ b/src/main/env_info.py @@ -7,7 +7,8 @@ env_type = 'venv' if env_type != 'venv' and os.path.exists(os.path.join(sys.prefix, "conda-meta")): - env_type = 'conda' + is_root = os.path.exists(os.path.join(sys.prefix, "condabin")) + env_type = 'conda-root' if is_root else 'conda-env' if env_type != 'system': env_name = os.path.basename(sys.prefix) diff --git a/src/main/registry.ts b/src/main/registry.ts index de4c6867..353fec0c 100644 --- a/src/main/registry.ts +++ b/src/main/registry.ts @@ -15,6 +15,7 @@ import { ArrayExt } from '@lumino/algorithm'; import * as fs from 'fs'; import log from 'electron-log'; import { + EnvironmentTypeName, IEnvironmentType, IPythonEnvironment, IVersionContainer @@ -38,6 +39,8 @@ export interface IRegistry { getEnvironmentList: () => Promise; + getCondaEnvironments(): Promise; + addEnvironment: (path: string) => Promise; getUserJupyterPath: () => Promise; @@ -46,6 +49,8 @@ export interface IRegistry { validatePythonEnvironmentAtPath: (path: string) => boolean; + validateCondaBaseEnvironmentAtPath: (envPath: string) => boolean; + setDefaultPythonPath: (path: string) => void; getCurrentPythonEnvironment: () => IPythonEnvironment; @@ -130,6 +135,7 @@ export class Registry implements IRegistry { this._setDefaultEnvironment(undefined); }); } else { + this._condaEnvironments = this._loadCondaEnvironments(); this._registryBuilt = Promise.resolve(); } } @@ -256,6 +262,14 @@ export class Registry implements IRegistry { }); } + /** + * Retrieve the list of conda environments, once they have been resolved + * @returns a promise that resolves to a list of conda environments + */ + getCondaEnvironments(): Promise { + return this._condaEnvironments; + } + /** * Create a new environment from a python executable, without waiting for the * entire registry to be resolved first. @@ -317,6 +331,16 @@ export class Registry implements IRegistry { return true; } + validateCondaBaseEnvironmentAtPath(envPath: string): boolean { + const isWin = process.platform === 'win32'; + const condaBinPath = path.join( + envPath, + 'condabin', + isWin ? 'conda.bat' : 'conda' + ); + return fs.existsSync(condaBinPath) && fs.lstatSync(condaBinPath).isFile(); + } + getEnvironmentInfo(pythonPath: string): IPythonEnvironment { const runOptions = { env: { PATH: this.getAdditionalPathIncludesForPythonPath(pythonPath) } @@ -339,13 +363,16 @@ export class Registry implements IRegistry { ); const envInfoOut = this._runCommandSync(pythonPath, ['-c', envInfoPyCode]); const envInfo = JSON.parse(envInfoOut.trim()); - const envName = `${envInfo.type}: ${envInfo.name}`; + const envType = + envInfo.type === 'conda-root' + ? IEnvironmentType.CondaRoot + : envInfo.type === 'conda-env' + ? IEnvironmentType.CondaEnv + : IEnvironmentType.VirtualEnv; + const envName = `${EnvironmentTypeName[envType]}: ${envInfo.name}`; return { - type: - envInfo.type === 'conda' - ? IEnvironmentType.CondaEnv - : IEnvironmentType.VirtualEnv, + type: envType, name: envName, path: pythonPath, versions: { python: pythonVersion, jupyterlab: jlabVersion }, @@ -486,6 +513,13 @@ export class Registry implements IRegistry { ); let allCondas = [pathCondas, commonCondas]; + + // add bundled conda env to the list of base conda envs + const bundledEnvPath = path.join(dirname(app.getAppPath()), 'jlab_server'); + if (fs.existsSync(path.join(bundledEnvPath, 'condabin'))) { + allCondas.unshift(Promise.resolve([bundledEnvPath])); + } + if (process.platform === 'win32') { allCondas.push(this._getWindowsRegistryCondas()); } @@ -1101,6 +1135,8 @@ export class Registry implements IRegistry { private _environments: IPythonEnvironment[] = []; + private _condaEnvironments: Promise; + private _default: IPythonEnvironment; private _registryBuilt: Promise; diff --git a/src/main/server.ts b/src/main/server.ts index d943ad37..ddbc5fd3 100644 --- a/src/main/server.ts +++ b/src/main/server.ts @@ -19,7 +19,7 @@ import * as os from 'os'; import * as path from 'path'; import * as http from 'http'; import { IEnvironmentType, IPythonEnvironment } from './tokens'; -import { appConfig, getSchemasDir } from './utils'; +import { appConfig, getEnvironmentPath, getSchemasDir } from './utils'; const SERVER_LAUNCH_TIMEOUT = 30000; // milliseconds const SERVER_RESTART_LIMIT = 3; // max server restarts @@ -40,15 +40,11 @@ function createTempFile( function createLaunchScript( environment: IPythonEnvironment, + baseCondaPath: string, schemasDir: string ): string { - const platform = process.platform; - const isWin = platform === 'win32'; - const pythonPath = environment.path; - let envPath = path.dirname(pythonPath); - if (!isWin) { - envPath = path.normalize(path.join(envPath, '../')); - } + const isWin = process.platform === 'win32'; + const envPath = getEnvironmentPath(environment); // note: traitlets<5.0 require fully specified arguments to // be followed by equals sign without a space; this can be @@ -70,21 +66,32 @@ function createLaunchScript( ].join(' '); let script: string; + const isConda = + environment.type === IEnvironmentType.CondaRoot || + environment.type === IEnvironmentType.CondaEnv; if (isWin) { - if (environment.type === IEnvironmentType.CondaEnv) { + if (isConda) { script = ` - CALL ${envPath}\\condabin\\activate.bat - CALL ${launchCmd}`; + CALL ${baseCondaPath}\\condabin\\activate.bat + CALL conda activate ${envPath} + CALL ${launchCmd}`; } else { script = ` - CALL ${envPath}\\activate.bat - CALL ${launchCmd}`; + CALL ${envPath}\\activate.bat + CALL ${launchCmd}`; } } else { - script = ` - source ${envPath}/bin/activate - ${launchCmd}`; + if (isConda) { + script = ` + source ${baseCondaPath}/bin/activate + conda activate ${envPath} + ${launchCmd}`; + } else { + script = ` + source ${envPath}/bin/activate + ${launchCmd}`; + } } const ext = isWin ? 'bat' : 'sh'; @@ -136,8 +143,14 @@ async function waitUntilServerIsUp(port: number): Promise { } export class JupyterServer { - constructor(options: JupyterServer.IOptions) { + constructor( + options: JupyterServer.IOptions, + app: IApplication, + registry: IRegistry + ) { this._info.environment = options.environment; + this._app = app; + this._registry = registry; } get info(): JupyterServer.IInfo { @@ -156,88 +169,152 @@ export class JupyterServer { } let started = false; - this._startServer = new Promise((resolve, reject) => { - const home = process.env.JLAB_DESKTOP_HOME || app.getPath('home'); - const isWin = process.platform === 'win32'; - const pythonPath = this._info.environment.path; - if (!fs.existsSync(pythonPath)) { - dialog.showMessageBox({ - message: `Environment not found at: ${pythonPath}`, - type: 'error' - }); - reject(); - } - this._info.url = `http://localhost:${appConfig.jlabPort}`; - this._info.token = appConfig.token; - - const launchScriptPath = createLaunchScript( - this._info.environment, - getSchemasDir() - ); - - this._nbServer = execFile(launchScriptPath, { - cwd: home, - shell: isWin ? 'cmd.exe' : '/bin/bash', - env: { - ...process.env, - JUPYTER_TOKEN: appConfig.token, - JUPYTER_CONFIG_DIR: - process.env.JLAB_DESKTOP_CONFIG_DIR || app.getPath('userData') + this._startServer = new Promise( + // eslint-disable-next-line no-async-promise-executor + async (resolve, reject) => { + const home = process.env.JLAB_DESKTOP_HOME || app.getPath('home'); + const isWin = process.platform === 'win32'; + const pythonPath = this._info.environment.path; + if (!fs.existsSync(pythonPath)) { + dialog.showMessageBox({ + message: `Environment not found at: ${pythonPath}`, + type: 'error' + }); + reject(); } - }); + this._info.url = `http://localhost:${appConfig.jlabPort}`; + this._info.token = appConfig.token; + + let baseCondaPath: string = ''; + if (this._info.environment.type === IEnvironmentType.CondaRoot) { + baseCondaPath = getEnvironmentPath(this._info.environment); + } else if (this._info.environment.type === IEnvironmentType.CondaEnv) { + const baseCondaPathSet = await this._app.getCondaRootPath(); + if (baseCondaPathSet && fs.existsSync(baseCondaPathSet)) { + baseCondaPath = baseCondaPathSet; + } else { + const environments = await this._registry.getCondaEnvironments(); + for (const environment of environments) { + if (environment.type === IEnvironmentType.CondaRoot) { + baseCondaPath = getEnvironmentPath(environment); + this._app.setCondaRootPath(baseCondaPath); + break; + } + } + } - Promise.race([ - waitUntilServerIsUp(appConfig.jlabPort), - waitForDuration(SERVER_LAUNCH_TIMEOUT) - ]).then((up: boolean) => { - if (up) { - started = true; - fs.unlinkSync(launchScriptPath); - resolve(this._info); - } else { - reject(new Error('Failed to launch Jupyter Server')); + if (baseCondaPath === '') { + const choice = dialog.showMessageBoxSync({ + message: 'Select conda base environment', + detail: + 'Base conda environment not found. Please select a root conda environment to activate the custom environment.', + type: 'error', + buttons: ['OK', 'Cancel'], + defaultId: 0, + cancelId: 0 + }); + if (choice == 1) { + reject(new Error('Failed to activate conda environment')); + return; + } + + const filePaths = dialog.showOpenDialogSync({ + properties: [ + 'openDirectory', + 'showHiddenFiles', + 'noResolveAliases' + ], + buttonLabel: 'Use Conda Root' + }); + + if (filePaths && filePaths.length > 0) { + baseCondaPath = filePaths[0]; + if ( + !this._registry.validateCondaBaseEnvironmentAtPath( + baseCondaPath + ) + ) { + reject(new Error('Invalid base conda environment')); + return; + } + this._app.setCondaRootPath(baseCondaPath); + } else { + reject(new Error('Failed to activate conda environment')); + return; + } + } } - }); - this._nbServer.on('exit', () => { - if (started) { - /* On Windows, JupyterLab server sometimes crashes randomly during websocket + const launchScriptPath = createLaunchScript( + this._info.environment, + baseCondaPath, + getSchemasDir() + ); + + this._nbServer = execFile(launchScriptPath, { + cwd: home, + shell: isWin ? 'cmd.exe' : '/bin/bash', + env: { + ...process.env, + JUPYTER_TOKEN: appConfig.token, + JUPYTER_CONFIG_DIR: + process.env.JLAB_DESKTOP_CONFIG_DIR || app.getPath('userData') + } + }); + + Promise.race([ + waitUntilServerIsUp(appConfig.jlabPort), + waitForDuration(SERVER_LAUNCH_TIMEOUT) + ]).then((up: boolean) => { + if (up) { + started = true; + fs.unlinkSync(launchScriptPath); + resolve(this._info); + } else { + reject(new Error('Failed to launch Jupyter Server')); + } + }); + + this._nbServer.on('exit', () => { + if (started) { + /* On Windows, JupyterLab server sometimes crashes randomly during websocket connection. As a result of this, users experience kernel connections failures. This crash only happens when server is launched from electron app. Since we haven't been able to detect the exact cause of these crashes we are restarting the server at the same port. After the restart, users are able to launch new kernels for the notebook. */ - this._cleanupListeners(); + this._cleanupListeners(); - if (!this._stopping && this._restartCount < SERVER_RESTART_LIMIT) { - started = false; - this._startServer = null; - this.start(); - this._restartCount++; + if (!this._stopping && this._restartCount < SERVER_RESTART_LIMIT) { + started = false; + this._startServer = null; + this.start(); + this._restartCount++; + } + } else { + this._serverStartFailed(); + reject( + new Error( + 'Jupyter Server process terminated before the initialization completed' + ) + ); } - } else { - this._serverStartFailed(); - reject( - new Error( - 'Jupyter Server process terminated before the initialization completed' - ) - ); - } - }); + }); - this._nbServer.on('error', (err: Error) => { - if (started) { - dialog.showMessageBox({ - message: `Jupyter Server process errored: ${err.message}`, - type: 'error' - }); - } else { - this._serverStartFailed(); - reject(err); - } - }); - }); + this._nbServer.on('error', (err: Error) => { + if (started) { + dialog.showMessageBox({ + message: `Jupyter Server process errored: ${err.message}`, + type: 'error' + }); + } else { + this._serverStartFailed(); + reject(err); + } + }); + } + ); return this._startServer; } @@ -305,6 +382,8 @@ export class JupyterServer { version: null }; + private _app: IApplication; + private _registry: IRegistry; private _stopping: boolean = false; private _restartCount: number = 0; } @@ -395,6 +474,7 @@ export namespace IServerFactory { export class JupyterServerFactory implements IServerFactory, IClosingService { constructor(app: IApplication, registry: IRegistry) { + this._app = app; this._registry = registry; app.registerClosingService(this); @@ -593,7 +673,7 @@ export class JupyterServerFactory implements IServerFactory, IClosingService { ): JupyterServerFactory.IFactoryItem { let item: JupyterServerFactory.IFactoryItem = { factoryId: this._nextId++, - server: new JupyterServer(opts), + server: new JupyterServer(opts, this._app, this._registry), closing: null, used: false }; @@ -669,6 +749,8 @@ export class JupyterServerFactory implements IServerFactory, IClosingService { private _nextId: number = 1; + private _app: IApplication; + private _registry: IRegistry; } diff --git a/src/main/tokens.ts b/src/main/tokens.ts index da18617e..69f15121 100644 --- a/src/main/tokens.ts +++ b/src/main/tokens.ts @@ -30,7 +30,7 @@ export enum IEnvironmentType { VirtualEnv = 'venv' } -export const EnvironmentTypeName = { +export const EnvironmentTypeName: { [key in IEnvironmentType]: string } = { [IEnvironmentType.PATH]: 'system', [IEnvironmentType.CondaRoot]: 'conda', [IEnvironmentType.CondaEnv]: 'conda', diff --git a/src/main/utils.ts b/src/main/utils.ts index 3e00d08a..1c898ab3 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -17,6 +17,7 @@ import * as path from 'path'; import * as fs from 'fs'; import log from 'electron-log'; import { app } from 'electron'; +import { IPythonEnvironment } from './tokens'; export interface IAppConfiguration { jlabPort: number; @@ -367,6 +368,17 @@ export function getSchemasDir(): string { return path.normalize(path.join(appDir, './build/schemas')); } +export function getEnvironmentPath(environment: IPythonEnvironment): string { + const isWin = process.platform === 'win32'; + const pythonPath = environment.path; + let envPath = path.dirname(pythonPath); + if (!isWin) { + envPath = path.normalize(path.join(envPath, '../')); + } + + return envPath; +} + let service: IService = { requirements: ['IApplication'], provides: 'IElectronDataConnector',