From 9615d2c26361bfc6ea124b5c7969cbeb7321c457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Dudfield?= Date: Sun, 28 Jan 2024 09:12:53 +0100 Subject: [PATCH] app: frontend: plugins: Add runCommand for shell commands in Electron app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So that some local shell commands can be run from inside a plugin. This communicates the process interaction with the Electron app render process via ipc. The interface exposed is a subset of the node spawn() which is what is used on the app side. It only implements the parts which are already used by the plugin we have in mind. The interface supports streaming stdout and stderr, and getting exit codes. Note, this supports running multiple commands at the same time. The complexities of this are hidden to the user of runCommand. It limits the commands that can be run to only minikube and az so far. If there is a need for more this can be extended later. This is intended as a way to limit potential misuse. The app-menu example plugin has been extended to add a minikube menu which displays minikube status. Signed-off-by: René Dudfield --- app/electron/main.ts | 65 +++++++++++++++++- app/electron/preload.ts | 15 +++- frontend/src/components/App/runCommand.ts | 83 +++++++++++++++++++++++ frontend/src/plugin/registry.tsx | 3 +- plugins/examples/app-menus/README.md | 2 + plugins/examples/app-menus/src/index.tsx | 44 +++++++++++- 6 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/App/runCommand.ts diff --git a/app/electron/main.ts b/app/electron/main.ts index efebb0d314..777fc16735 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -98,7 +98,10 @@ function startServer(flags: string[] = []): ChildProcessWithoutNullStreams { */ function isWSL(): boolean { try { - const data = fs.readFileSync('/proc/version', { encoding: 'utf8', flag: 'r' }); + const data = fs.readFileSync('/proc/version', { + encoding: 'utf8', + flag: 'r', + }); return data.indexOf('icrosoft') !== -1; } catch { return false; @@ -644,6 +647,66 @@ function startElecron() { } }); + /** + * Data sent from the renderer process when a 'run-command' event is emitted. + */ + interface CommandData { + /** The unique ID of the command. */ + id: string; + /** The command to run. */ + command: string; + /** The arguments to pass to the command. */ + args: string[]; + /** + * Options to pass to the command. + * See https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options + */ + options: {}; + } + + /** + * Handles 'run-command' events from the renderer process. + * + * Spawns the requested command and sends 'command-stdout', + * 'command-stderr', and 'command-exit' events back to the renderer + * process with the command's output and exit code. + * + * @param event - The event object. + * @param eventData - The data sent from the renderer process. + */ + function handleRunCommand(event: IpcMainEvent, eventData: CommandData): void { + // Only allow "minikube", and "az" commands + const validCommands = ['minikube', 'az']; + if (!validCommands.includes(eventData.command)) { + console.error( + `Invalid command: ${eventData.command}, only valid commands are: ${JSON.stringify( + validCommands + )}` + ); + return; + } + + const child: ChildProcessWithoutNullStreams = spawn( + eventData.command, + eventData.args, + eventData.options + ); + + child.stdout.on('data', (data: string | Buffer) => { + event.sender.send('command-stdout', eventData.id, data.toString()); + }); + + child.stderr.on('data', (data: string | Buffer) => { + event.sender.send('command-stderr', eventData.id, data.toString()); + }); + + child.on('exit', (code: number | null) => { + event.sender.send('command-exit', eventData.id, code); + }); + } + + ipcMain.on('run-command', handleRunCommand); + if (!useExternalServer) { const runningHeadlamp = await getRunningHeadlampPIDs(); let shouldWaitForKill = true; diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 5cd7218cad..92b4b0cb64 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -1,19 +1,28 @@ import { contextBridge, ipcRenderer } from 'electron'; + // Expose protected methods that allow the renderer process to use // the ipcRenderer without exposing the entire object contextBridge.exposeInMainWorld('desktopApi', { send: (channel, data) => { // allowed channels - const validChannels = ['setMenu', 'locale', 'appConfig', 'pluginsLoaded']; + const validChannels = ['setMenu', 'locale', 'appConfig', 'pluginsLoaded', 'run-command']; if (validChannels.includes(channel)) { ipcRenderer.send(channel, data); } }, receive: (channel, func) => { - const validChannels = ['currentMenu', 'setMenu', 'locale', 'appConfig']; + const validChannels = [ + 'currentMenu', + 'setMenu', + 'locale', + 'appConfig', + 'command-stdout', + 'command-stderr', + 'command-exit', + ]; if (validChannels.includes(channel)) { // Deliberately strip event as it includes `sender` - ipcRenderer.on(channel, ({}, ...args) => func(...args)); + ipcRenderer.on(channel, (event, ...args) => func(...args)); } }, }); diff --git a/frontend/src/components/App/runCommand.ts b/frontend/src/components/App/runCommand.ts new file mode 100644 index 0000000000..3a86fa0280 --- /dev/null +++ b/frontend/src/components/App/runCommand.ts @@ -0,0 +1,83 @@ +/** + * Runs a shell command and returns an object that mimics the interface of a ChildProcess object returned by Node's spawn function. + * + * This function is intended to be used only when Headlamp is in app mode. + * + * @see handleRunCommand in app/electron/main.ts + * + * This function uses the desktopApi.send and desktopApi.receive methods to communicate with the main process. + * @param command - The command to run. + * @param args - An array of arguments to pass to the command. + * @returns An object with `stdout`, `stderr`, and `on` properties. You can listen for 'data' events on `stdout` and `stderr`, and 'exit' events with `on`. + * @example + * + * ```ts + * const minikube = runCommand('minikube', ['status']); + * minikube.stdout.on('data', (data) => { + * console.log('stdout:', data); + * }); + * minikube.stderr.on('data', (data) => { + * console.log('stderr:', data); + * }); + * minikube.on('exit', (code) => { + * console.log('exit code:', code); + * }); + * ``` + */ +export function runCommand( + command: 'minikube' | 'az', + args: string[], + options: {} +): { + stdout: { on: (event: string, listener: (chunk: any) => void) => void }; + stderr: { on: (event: string, listener: (chunk: any) => void) => void }; + on: (event: string, listener: (code: number | null) => void) => void; +} { + if (!window.desktopApi) { + throw new Error('runCommand only works in Headlamp app mode.'); + } + + // Generate a unique ID for the command, so that we can distinguish between + // multiple commands running at the same time. + const id = `${new Date().getTime()}-${Math.random().toString(36)}`; + + const stdout = new EventTarget(); + const stderr = new EventTarget(); + const exit = new EventTarget(); + + window.desktopApi.send('run-command', { id, command, args, options }); + + window.desktopApi.receive('command-stdout', (cmdId: string, data: string) => { + if (cmdId === id) { + const event = new CustomEvent('data', { detail: data }); + stdout.dispatchEvent(event); + } + }); + + window.desktopApi.receive('command-stderr', (cmdId: string, data: string) => { + if (cmdId === id) { + const event = new CustomEvent('data', { detail: data }); + stderr.dispatchEvent(event); + } + }); + + window.desktopApi.receive('command-exit', (cmdId: string, code: number) => { + if (cmdId === id) { + const event = new CustomEvent('exit', { detail: code }); + exit.dispatchEvent(event); + } + }); + + return { + stdout: { + on: (event: string, listener: (chunk: any) => void) => + stdout.addEventListener(event, (e: any) => listener(e.detail)), + }, + stderr: { + on: (event: string, listener: (chunk: any) => void) => + stderr.addEventListener(event, (e: any) => listener(e.detail)), + }, + on: (event: string, listener: (code: number | null) => void) => + exit.addEventListener(event, (e: any) => listener(e.detail)), + }; +} diff --git a/frontend/src/plugin/registry.tsx b/frontend/src/plugin/registry.tsx index 70810c9b31..f94aec3122 100644 --- a/frontend/src/plugin/registry.tsx +++ b/frontend/src/plugin/registry.tsx @@ -1,6 +1,7 @@ import { has } from 'lodash'; import React from 'react'; import { AppLogoProps, AppLogoType } from '../components/App/AppLogo'; +import { runCommand } from '../components/App/runCommand'; import { setBrandingAppLogoComponent } from '../components/App/themeSlice'; import { ClusterChooserProps, ClusterChooserType } from '../components/cluster/ClusterChooser'; import { @@ -559,4 +560,4 @@ export function registerGetTokenFunction(override: (cluster: string) => string | store.dispatch(setFunctionsToOverride({ getToken: override })); } -export { DefaultAppBarAction, DefaultDetailsViewSection, getHeadlampAPIHeaders }; +export { DefaultAppBarAction, DefaultDetailsViewSection, getHeadlampAPIHeaders, runCommand }; diff --git a/plugins/examples/app-menus/README.md b/plugins/examples/app-menus/README.md index d9756d53f6..fa12fb4785 100644 --- a/plugins/examples/app-menus/README.md +++ b/plugins/examples/app-menus/README.md @@ -4,6 +4,8 @@ Add menus when Headlamp is running as an app. ![screenshot of the custom details view section and action button](../../../docs/development/plugins/images/app-menus.png) +It also shows how to run some commands locally in the app. + To run the plugin: ```bash diff --git a/plugins/examples/app-menus/src/index.tsx b/plugins/examples/app-menus/src/index.tsx index da3339a04f..fd877e5728 100644 --- a/plugins/examples/app-menus/src/index.tsx +++ b/plugins/examples/app-menus/src/index.tsx @@ -1,4 +1,4 @@ -import { Headlamp, Plugin } from '@kinvolk/headlamp-plugin/lib'; +import { Headlamp, Plugin, runCommand } from '@kinvolk/headlamp-plugin/lib'; class AppMenuDemo extends Plugin { static warnedOnce = false; @@ -36,6 +36,48 @@ class AppMenuDemo extends Plugin { } return menus; }); + + // Let's show the status of a minikube command if it's installed. + // In app mode we can run a few local commands (only minikube and az so far). + + function minikubeMenu() { + const minikube = runCommand('minikube', ['status']); + const output = []; + + minikube.stdout.on('data', data => { + output.push(data); + }); + + minikube.on('exit', code => { + if (code === 0) { + Headlamp.setAppMenu(menus => { + let minikubeMenu = menus?.find(menu => menu.id === 'custom-menu-minikube') || null; + if (!minikubeMenu) { + minikubeMenu = { + label: 'Minikube', + id: 'custom-menu-minikube', + submenu: output + .join('') + .split('\n') + .filter(line => line !== '') + .map(line => { + return { + label: line, + enabled: false, + }; + }), + }; + + menus.push(minikubeMenu); + } + return menus; + }); + } + }); + } + minikubeMenu(); + // run the command every 5 seconds. + setTimeout(minikubeMenu, 5000); } }