Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

app: frontend: plugins: Add runCommand for shell commands in Electron app #1677

Merged
merged 1 commit into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
illume marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading