Skip to content

Commit

Permalink
Merge pull request #1677 from headlamp-k8s/runCommand
Browse files Browse the repository at this point in the history
app: frontend: plugins: Add runCommand for shell commands in Electron app
  • Loading branch information
illume authored Jan 30, 2024
2 parents 944c336 + 9615d2c commit d0aea57
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 6 deletions.
65 changes: 64 additions & 1 deletion app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 12 additions & 3 deletions app/electron/preload.ts
Original file line number Diff line number Diff line change
@@ -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));
}
},
});
83 changes: 83 additions & 0 deletions frontend/src/components/App/runCommand.ts
Original file line number Diff line number Diff line change
@@ -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)),
};
}
3 changes: 2 additions & 1 deletion frontend/src/plugin/registry.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 };
2 changes: 2 additions & 0 deletions plugins/examples/app-menus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 43 additions & 1 deletion plugins/examples/app-menus/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down

0 comments on commit d0aea57

Please sign in to comment.