diff --git a/package.json b/package.json index e82c44f9..438364df 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dist:osx-64": "yarn dist:osx", "dist:osx-arm64": "yarn dist:osx", "dist:osx-dev": "yarn build && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --macos --publish never", + "dist:osx-arm64-dev": "yarn build && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --macos --arm64 --publish never", "dist:win-64": "yarn build && electron-builder --win --publish never", "dist:win-arm64": "yarn build && yarn electron-builder --arm64 --publish never", "update_workflow_conda_lock": "cd workflow_env && rimraf *.lock && conda-lock --kind explicit -f publish_env.yaml && cd -", diff --git a/src/main/app.ts b/src/main/app.ts index 9babbf0e..43d1ea20 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -24,6 +24,7 @@ import { installBundledEnvironment, isDarkTheme, pythonPathForEnvPath, + setupJlabCLICommandWithElevatedRights, waitForDuration } from './utils'; import { IServerFactory, JupyterServerFactory } from './server'; @@ -1201,6 +1202,29 @@ export class JupyterApplication implements IApplication, IDisposable { return true; } ); + + this._evm.registerSyncEventHandler( + EventTypeMain.SetupCLICommandWithElevatedRights, + async event => { + const showSetupErrorMessage = () => { + dialog.showErrorBox( + 'CLI setup error', + 'Failed to setup jlab CLI command! Please see logs for details.' + ); + }; + + try { + const succeeded = await setupJlabCLICommandWithElevatedRights(); + if (!succeeded) { + showSetupErrorMessage(); + } + return succeeded; + } catch (error) { + showSetupErrorMessage(); + return false; + } + } + ); } private _showUpdateDialog( diff --git a/src/main/eventtypes.ts b/src/main/eventtypes.ts index 7234ae7e..883205c4 100644 --- a/src/main/eventtypes.ts +++ b/src/main/eventtypes.ts @@ -81,7 +81,8 @@ export enum EventTypeMain { SetSystemPythonPath = 'set-system-python-path', CopySessionInfoToClipboard = 'copy-session-info-to-clipboard', RestartSession = 'restart-session', - SetSettings = 'set-settings' + SetSettings = 'set-settings', + SetupCLICommandWithElevatedRights = 'setup-cli-command' } // events sent to Renderer process diff --git a/src/main/main.ts b/src/main/main.ts index 95c73698..114a9cb2 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -5,16 +5,16 @@ import * as semver from 'semver'; import { bundledEnvironmentIsInstalled, EnvironmentInstallStatus, - getAppDir, getBundledPythonEnvPath, getBundledPythonPath, installBundledEnvironment, isDevMode, + jlabCLICommandIsSetup, + setupJlabCommandWithUserRights, versionWithoutSuffix, waitForDuration, waitForFunction } from './utils'; -import { execSync } from 'child_process'; import { JupyterApplication } from './app'; import { ICLIArguments } from './tokens'; import { SessionConfig } from './config/sessionconfig'; @@ -130,25 +130,11 @@ function setupJLabCommand() { return; } - const symlinkPath = '/usr/local/bin/jlab'; - const targetPath = `${getAppDir()}/app/jlab`; - - if (!fs.existsSync(targetPath)) { + if (jlabCLICommandIsSetup()) { return; } - try { - if (!fs.existsSync(symlinkPath)) { - const cmd = `ln -s ${targetPath} ${symlinkPath}`; - execSync(cmd, { shell: '/bin/bash' }); - fs.chmodSync(symlinkPath, 0o755); - } - - // after a DMG install, mode resets - fs.chmodSync(targetPath, 0o755); - } catch (error) { - log.error(error); - } + setupJlabCommandWithUserRights(); } function createPythonEnvsDirectory() { diff --git a/src/main/settingsdialog/preload.ts b/src/main/settingsdialog/preload.ts index e583dc88..6aa9973f 100644 --- a/src/main/settingsdialog/preload.ts +++ b/src/main/settingsdialog/preload.ts @@ -78,6 +78,9 @@ contextBridge.exposeInMainWorld('electronAPI', { }, setSettings: (settings: { [key: string]: any }) => { ipcRenderer.send(EventTypeMain.SetSettings, settings); + }, + setupCLICommand: () => { + return ipcRenderer.invoke(EventTypeMain.SetupCLICommandWithElevatedRights); } }); diff --git a/src/main/settingsdialog/settingsdialog.ts b/src/main/settingsdialog/settingsdialog.ts index be374829..f92efe85 100644 --- a/src/main/settingsdialog/settingsdialog.ts +++ b/src/main/settingsdialog/settingsdialog.ts @@ -16,6 +16,7 @@ import { ThemeType } from '../config/settings'; import { IRegistry } from '../registry'; +import { jlabCLICommandIsSetup } from '../utils'; export class SettingsDialog { constructor(options: SettingsDialog.IOptions, registry: IRegistry) { @@ -23,7 +24,7 @@ export class SettingsDialog { isDarkTheme: options.isDarkTheme, title: 'Settings', width: 700, - height: 450, + height: 500, preload: path.join(__dirname, './preload.js') }); @@ -45,6 +46,7 @@ export class SettingsDialog { const installUpdatesAutomaticallyEnabled = process.platform === 'darwin'; const installUpdatesAutomatically = installUpdatesAutomaticallyEnabled && options.installUpdatesAutomatically; + const cliCommandIsSetup = jlabCLICommandIsSetup(); let strServerEnvVars = ''; if (Object.keys(serverEnvVars).length > 0) { @@ -336,6 +338,19 @@ export class SettingsDialog { +
+
+ +
+ +
+
+ Setup CLI +
+
+
+
+
@@ -376,6 +391,9 @@ export class SettingsDialog { const autoInstallCheckbox = document.getElementById('checkbox-update-install'); const notifyOnBundledEnvUpdatesCheckbox = document.getElementById('notify-on-bundled-env-updates'); const updateBundledEnvAutomaticallyCheckbox = document.getElementById('update-bundled-env-automatically'); + const setupCLICommandButton = document.getElementById('setup-cli-command-button'); + const setupCLICommandLabel = document.getElementById('setup-cli-command-label'); + let cliCommandIsSetup = <%= cliCommandIsSetup %>; function handleAutoCheckForUpdates(el) { updateAutoInstallCheckboxState(); @@ -398,11 +416,29 @@ export class SettingsDialog { window.electronAPI.showLogs(); } + function handleSetupCLICommand(el) { + window.electronAPI.setupCLICommand().then(result => { + cliCommandIsSetup = result; + updateCLICommandSetupStatus(); + }); + } + + function updateCLICommandSetupStatus() { + if (cliCommandIsSetup) { + setupCLICommandButton.style.display = 'none'; + setupCLICommandLabel.innerHTML = 'jlab CLI command is ready to use in your system terminal!'; + } else { + setupCLICommandButton.style.display = 'block'; + setupCLICommandLabel.innerHTML = 'CLI command is not set up yet. Click to set up now. This requires elevated permissions.'; + } + } + function onLogLevelChanged(el) { window.electronAPI.setLogLevel(el.value); } document.addEventListener("DOMContentLoaded", () => { + updateCLICommandSetupStatus(); updateAutoInstallCheckboxState(); }); @@ -480,7 +516,8 @@ export class SettingsDialog { serverArgs, overrideDefaultServerArgs, serverEnvVars: strServerEnvVars, - ctrlWBehavior + ctrlWBehavior, + cliCommandIsSetup }); } diff --git a/src/main/utils.ts b/src/main/utils.ts index 3630457c..1692d62e 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -10,7 +10,13 @@ import log from 'electron-log'; import { AddressInfo, createServer, Socket } from 'net'; import { app, nativeTheme } from 'electron'; import { IPythonEnvironment } from './tokens'; -import { exec, execFile, ExecFileOptions, execFileSync } from 'child_process'; +import { + exec, + execFile, + ExecFileOptions, + execFileSync, + execSync +} from 'child_process'; export const DarkThemeBGColor = '#212121'; export const LightThemeBGColor = '#ffffff'; @@ -710,3 +716,117 @@ export function launchTerminalInDirectory( exec(`gnome-terminal --working-directory="${dirPath}"${callCommands}`); } } + +export function getJlabCLICommandSymlinkPath(): string { + if (process.platform === 'darwin') { + return '/usr/local/bin/jlab'; + } +} + +export function getJlabCLICommandTargetPath(): string { + if (process.platform === 'darwin') { + return `${getAppDir()}/app/jlab`; + } +} + +export function jlabCLICommandIsSetup(): boolean { + if (process.platform !== 'darwin') { + return true; + } + + const symlinkPath = getJlabCLICommandSymlinkPath(); + const targetPath = getJlabCLICommandTargetPath(); + + if (!fs.existsSync(symlinkPath)) { + return false; + } + + const stats = fs.lstatSync(symlinkPath); + if (!stats.isSymbolicLink()) { + return false; + } + + try { + fs.accessSync(targetPath, fs.constants.X_OK); + } catch (error) { + log.error('App CLI is not executable', error); + return false; + } + + return fs.readlinkSync(symlinkPath) === targetPath; +} + +export async function setupJlabCLICommandWithElevatedRights(): Promise< + boolean +> { + if (process.platform !== 'darwin') { + return false; + } + + const symlinkPath = getJlabCLICommandSymlinkPath(); + const targetPath = getJlabCLICommandTargetPath(); + + if (!fs.existsSync(targetPath)) { + log.error(`Target path "${targetPath}" does not exist! `); + return false; + } + + const shellCommands: string[] = []; + const symlinkParentDir = path.dirname(symlinkPath); + + // create parent directory + if (!fs.existsSync(symlinkParentDir)) { + shellCommands.push(`mkdir -p ${symlinkParentDir}`); + } + + // create symlink + shellCommands.push(`ln -f -s \\"${targetPath}\\" \\"${symlinkPath}\\"`); + + // make files executable + shellCommands.push(`chmod 755 \\"${symlinkPath}\\"`); + shellCommands.push(`chmod 755 \\"${targetPath}\\"`); + + const command = `do shell script "${shellCommands.join( + ' && ' + )}" with administrator privileges`; + + return new Promise((resolve, reject) => { + const cliSetupProc = exec(`osascript -e '${command}'`); + + cliSetupProc.on('exit', (exitCode: number) => { + if (exitCode === 0) { + resolve(true); + } else { + log.error(`Failed to setup CLI with exit code ${exitCode}`); + reject(); + } + }); + + cliSetupProc.on('error', (err: Error) => { + log.error(err); + reject(); + }); + }); +} + +export async function setupJlabCommandWithUserRights() { + const symlinkPath = getJlabCLICommandSymlinkPath(); + const targetPath = getJlabCLICommandTargetPath(); + + if (!fs.existsSync(targetPath)) { + return; + } + + try { + if (!fs.existsSync(symlinkPath)) { + const cmd = `ln -s ${targetPath} ${symlinkPath}`; + execSync(cmd, { shell: '/bin/bash' }); + fs.chmodSync(symlinkPath, 0o755); + } + + // after a DMG install, mode resets + fs.chmodSync(targetPath, 0o755); + } catch (error) { + log.error(error); + } +}