Skip to content
Open
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
110 changes: 107 additions & 3 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
addToPath,
ArtifactHubHeadlampPkg,
defaultPluginsDir,
defaultUserPluginsDir,
getMatchingExtraFiles,
getPluginBinDirectories,
PluginManager,
Expand Down Expand Up @@ -422,6 +423,9 @@ class PluginManagerEventListeners {
/**
* Handles the list event.
*
* Lists plugins from all three directories (shipped, user-installed, development)
* and returns a combined list with their locations.
*
* @method
* @name handleList
* @param {Electron.IpcMainEvent} event - The IPC Main Event.
Expand All @@ -430,9 +434,84 @@ class PluginManagerEventListeners {
*/
private handleList(event: Electron.IpcMainEvent, eventData: Action) {
const { identifier, destinationFolder } = eventData;
PluginManager.list(destinationFolder, progress => {
event.sender.send('plugin-manager', JSON.stringify({ identifier: identifier, ...progress }));
});

// If a specific folder is requested, list only from that folder
if (destinationFolder) {
PluginManager.list(destinationFolder, progress => {
event.sender.send(
'plugin-manager',
JSON.stringify({ identifier: identifier, ...progress })
);
});
return;
}

// Otherwise, list from all three directories
try {
const allPlugins: any[] = [];

// List from shipped plugins (.plugins)
const shippedDir = path.join(__dirname, '.plugins');
try {
const shippedPlugins = PluginManager.list(shippedDir);
if (shippedPlugins) {
allPlugins.push(...shippedPlugins);
}
} catch (error: any) {
// Only ignore if directory doesn't exist, log other errors
if (error?.code !== 'ENOENT') {
console.error('Error listing shipped plugins:', error);
}
}
Comment on lines +460 to +465
Copy link

Copilot AI Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error messages in the three catch blocks are generic and could be more helpful by including the directory path that failed. For example: Error listing shipped plugins from ${shippedDir}: ${error}

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is worth fixing.


// List from user-installed plugins (user-plugins)
const userDir = defaultUserPluginsDir();
try {
const userPlugins = PluginManager.list(userDir);
if (userPlugins) {
allPlugins.push(...userPlugins);
}
} catch (error: any) {
// Only ignore if directory doesn't exist, log other errors
if (error?.code !== 'ENOENT') {
console.error('Error listing user plugins:', error);
}
}

// List from development plugins (plugins)
const devDir = defaultPluginsDir();
try {
const devPlugins = PluginManager.list(devDir);
if (devPlugins) {
allPlugins.push(...devPlugins);
}
} catch (error: any) {
// Only ignore if directory doesn't exist, log other errors
if (error?.code !== 'ENOENT') {
console.error('Error listing development plugins:', error);
}
}

// Send combined results
event.sender.send(
'plugin-manager',
JSON.stringify({
identifier: identifier,
type: 'success',
message: 'Plugins Listed',
data: allPlugins,
})
);
} catch (error) {
event.sender.send(
'plugin-manager',
JSON.stringify({
identifier: identifier,
type: 'error',
message: error instanceof Error ? error.message : String(error),
})
);
}
}

/**
Expand Down Expand Up @@ -1590,6 +1669,31 @@ function startElecron() {

new PluginManagerEventListeners().setupEventHandlers();

// Handle opening plugin folder in file explorer
ipcMain.on(
'open-plugin-folder',
(
event: IpcMainEvent,
pluginInfo: { folderName: string; type: 'development' | 'user' | 'shipped' }
) => {
let folderPath: string | null = null;

if (pluginInfo.type === 'user') {
folderPath = path.join(defaultUserPluginsDir(), pluginInfo.folderName);
} else if (pluginInfo.type === 'development') {
folderPath = path.join(defaultPluginsDir(), pluginInfo.folderName);
} else if (pluginInfo.type === 'shipped') {
folderPath = path.join(process.resourcesPath, '.plugins', pluginInfo.folderName);
}

if (folderPath) {
shell.openPath(folderPath).catch((err: Error) => {
console.error('Failed to open plugin folder:', err);
});
}
}
);

// Also add bundled plugin bin directories to PATH
const bundledPlugins = path.join(process.resourcesPath, '.plugins');
const bundledPluginBinDirs = getPluginBinDirectories(bundledPlugins);
Expand Down
26 changes: 20 additions & 6 deletions app/electron/plugin-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,15 @@ export class PluginManager {
/**
* Installs a plugin from the specified URL.
* @param {string} URL - The URL of the plugin to install.
* @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin will be installed.
* @param {string} [destinationFolder=defaultUserPluginsDir()] - The folder where the plugin will be installed.
* @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking.
* @param {function} [progressCallback=null] - Optional callback for progress updates.
* @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation.
* @returns {Promise<void>} A promise that resolves when the installation is complete.
*/
static async install(
URL: string,
destinationFolder: string = defaultPluginsDir(),
destinationFolder: string = defaultUserPluginsDir(),
headlampVersion: string = '',
progressCallback: null | ProgressCallback = null,
signal: AbortSignal | null = null
Expand Down Expand Up @@ -178,15 +178,15 @@ export class PluginManager {
/**
* Installs a plugin from the given plugin data.
* @param {PluginData} pluginData - The plugin data from which to install the plugin.
* @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin will be installed.
* @param {string} [destinationFolder=defaultUserPluginsDir()] - The folder where the plugin will be installed.
* @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking.
* @param {function} [progressCallback=null] - Optional callback for progress updates.
* @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation.
* @returns {Promise<void>} A promise that resolves when the installation is complete.
*/
static async installFromPluginPkg(
pluginData: ArtifactHubHeadlampPkg,
destinationFolder = defaultPluginsDir(),
destinationFolder = defaultUserPluginsDir(),
headlampVersion = '',
progressCallback: null | ProgressCallback = null,
signal: AbortSignal | null = null
Expand Down Expand Up @@ -230,15 +230,15 @@ export class PluginManager {
/**
* Updates an installed plugin to the latest version.
* @param {string} pluginName - The name of the plugin to update.
* @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin is installed.
* @param {string} [destinationFolder=defaultUserPluginsDir()] - The folder where the plugin is installed.
* @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking.
* @param {null | ProgressCallback} [progressCallback=null] - Optional callback for progress updates.
* @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation.
* @returns {Promise<void>} A promise that resolves when the update is complete.
*/
static async update(
pluginName: string,
destinationFolder: string = defaultPluginsDir(),
destinationFolder: string = defaultUserPluginsDir(),
headlampVersion: string = '',
progressCallback: null | ProgressCallback = null,
signal: AbortSignal | null = null
Expand Down Expand Up @@ -1020,6 +1020,20 @@ export function defaultPluginsDir() {
return path.join(configDir, 'plugins');
}

/**
* Returns the default directory where user-installed plugins are stored.
* If the data path exists, it is used as the base directory.
* Otherwise, the config path is used as the base directory.
* The 'user-plugins' subdirectory of the base directory is returned.
*
* @returns {string} The path to the default user-plugins directory.
*/
export function defaultUserPluginsDir() {
const paths = envPaths('Headlamp', { suffix: '' });
const configDir = fs.existsSync(paths.data) ? paths.data : paths.config;
return path.join(configDir, 'user-plugins');
}

/**
* Checks if a given folder is a valid plugin bin folder.
*
Expand Down
1 change: 1 addition & 0 deletions app/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ contextBridge.exposeInMainWorld('desktopApi', {
'plugin-manager',
'request-backend-token',
'request-plugin-permission-secrets',
'open-plugin-folder',
'request-backend-port',
];
if (validChannels.includes(channel)) {
Expand Down
51 changes: 43 additions & 8 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ func defaultHeadlampKubeConfigFile() (string, error) {

// addPluginRoutes adds plugin routes to a router.
// It serves plugin list base paths as json at "plugins".
// It serves plugin static files at "plugins/" and "static-plugins/".
// It serves plugin static files at "plugins/", "user-plugins/" and "static-plugins/".
// It disables caching and reloads plugin list base paths if not in-cluster.
func addPluginRoutes(config *HeadlampConfig, r *mux.Router) {
// Delete plugin route.
Expand All @@ -266,7 +266,7 @@ func addPluginRoutes(config *HeadlampConfig, r *mux.Router) {

addPluginListRoute(config, r)

// Serve plugins
// Serve development plugins
pluginHandler := http.StripPrefix(config.BaseURL+"/plugins/", http.FileServer(http.Dir(config.PluginDir)))
// If we're running locally, then do not cache the plugins. This ensures that reloading them (development,
// update) will actually get the new content.
Expand All @@ -276,6 +276,18 @@ func addPluginRoutes(config *HeadlampConfig, r *mux.Router) {

r.PathPrefix("/plugins/").Handler(pluginHandler)

// Serve user-installed plugins
if config.UserPluginDir != "" {
userPluginsHandler := http.StripPrefix(config.BaseURL+"/user-plugins/",
http.FileServer(http.Dir(config.UserPluginDir)))
if !config.UseInCluster {
userPluginsHandler = serveWithNoCacheHeader(userPluginsHandler)
}

r.PathPrefix("/user-plugins/").Handler(userPluginsHandler)
}

// Serve shipped/static plugins
if config.StaticPluginDir != "" {
staticPluginsHandler := http.StripPrefix(config.BaseURL+"/static-plugins/",
http.FileServer(http.Dir(config.StaticPluginDir)))
Expand All @@ -291,6 +303,9 @@ func addPluginDeleteRoute(config *HeadlampConfig, r *mux.Router) {
var span trace.Span
pluginName := mux.Vars(r)["name"]

// Get plugin type from query parameter (optional)
pluginType := r.URL.Query().Get("type")

// Start tracing for deletePlugin.
if config.Telemetry != nil {
_, span = telemetry.CreateSpan(ctx, r, "plugins", "deletePlugin",
Expand All @@ -313,12 +328,12 @@ func addPluginDeleteRoute(config *HeadlampConfig, r *mux.Router) {
return
}

err := plugins.Delete(config.PluginDir, pluginName)
err := plugins.Delete(config.UserPluginDir, config.PluginDir, pluginName, pluginType)
if err != nil {
config.telemetryHandler.RecordError(span, err, "Failed to delete plugin")

logger.Log(logger.LevelError, nil, err, "Error deleting plugin: "+pluginName)
http.Error(w, "Error deleting plugin", http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logger.Log(logger.LevelInfo, nil, nil, "Plugin deleted successfully: "+pluginName)
Expand Down Expand Up @@ -351,13 +366,13 @@ func addPluginListRoute(config *HeadlampConfig, r *mux.Router) {
w.Header().Set("Content-Type", "application/json")
pluginsList, err := config.cache.Get(context.Background(), plugins.PluginListKey)
if err != nil && err == cache.ErrNotFound {
pluginsList = []string{}
pluginsList = []plugins.PluginMetadata{}

if config.Telemetry != nil {
span.SetAttributes(attribute.Int("plugins.count", 0))
}
} else if config.Telemetry != nil && pluginsList != nil {
if list, ok := pluginsList.([]string); ok {
if list, ok := pluginsList.([]plugins.PluginMetadata); ok {
span.SetAttributes(attribute.Int("plugins.count", len(list)))
}
}
Expand Down Expand Up @@ -386,6 +401,7 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
logger.Log(logger.LevelInfo, nil, nil, "Listen address: "+fmt.Sprintf("%s:%d", config.ListenAddr, config.Port))
logger.Log(logger.LevelInfo, nil, nil, "Kubeconfig path: "+kubeConfigPath)
logger.Log(logger.LevelInfo, nil, nil, "Static plugin dir: "+config.StaticPluginDir)
logger.Log(logger.LevelInfo, nil, nil, "User plugins dir: "+config.UserPluginDir)
logger.Log(logger.LevelInfo, nil, nil, "Plugins dir: "+config.PluginDir)
logger.Log(logger.LevelInfo, nil, nil, "Dynamic clusters support: "+fmt.Sprint(config.EnableDynamicClusters))
logger.Log(logger.LevelInfo, nil, nil, "Helm support: "+fmt.Sprint(config.EnableHelm))
Expand All @@ -400,15 +416,34 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler {
logger.Log(logger.LevelInfo, nil, nil, "Use In Cluster: "+fmt.Sprint(config.UseInCluster))
logger.Log(logger.LevelInfo, nil, nil, "Watch Plugins Changes: "+fmt.Sprint(config.WatchPluginsChanges))

plugins.PopulatePluginsCache(config.StaticPluginDir, config.PluginDir, config.cache)
plugins.PopulatePluginsCache(config.StaticPluginDir, config.UserPluginDir, config.PluginDir, config.cache)

skipFunc := kubeconfig.SkipKubeContextInCommaSeparatedString(config.SkippedKubeContexts)

if !config.UseInCluster || config.WatchPluginsChanges {
// in-cluster mode is unlikely to want reloading plugins.
pluginEventChan := make(chan string)
go plugins.Watch(config.PluginDir, pluginEventChan)
go plugins.HandlePluginEvents(config.StaticPluginDir, config.PluginDir, pluginEventChan, config.cache)

// Watch user-plugins directory for catalog-installed plugins
if config.UserPluginDir != "" {
userPluginEventChan := make(chan string)
go plugins.Watch(config.UserPluginDir, userPluginEventChan)
// Merge both event channels into one
go func() {
for event := range userPluginEventChan {
pluginEventChan <- event
}
}()
}

go plugins.HandlePluginEvents(
config.StaticPluginDir,
config.UserPluginDir,
config.PluginDir,
pluginEventChan,
config.cache,
)
// in-cluster mode is unlikely to want reloading kubeconfig.
go kubeconfig.LoadAndWatchFiles(config.KubeConfigStore, kubeConfigPath, kubeconfig.KubeConfig, skipFunc)
}
Expand Down
17 changes: 14 additions & 3 deletions backend/cmd/headlamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,8 +526,18 @@ func TestDeletePlugin(t *testing.T) {

defer os.RemoveAll(tempDir)

// create plugin
pluginDir := tempDir + "/test-plugin"
// create user-plugins dir
userPluginDir := tempDir + "/user-plugins"
err = os.Mkdir(userPluginDir, 0o755)
require.NoError(t, err)

// create dev plugins dir
devPluginDir := tempDir + "/plugins"
err = os.Mkdir(devPluginDir, 0o755)
require.NoError(t, err)

// create plugin in dev dir
pluginDir := devPluginDir + "/test-plugin"
err = os.Mkdir(pluginDir, 0o755)
require.NoError(t, err)

Expand All @@ -543,7 +553,8 @@ func TestDeletePlugin(t *testing.T) {
HeadlampCFG: &headlampconfig.HeadlampCFG{
UseInCluster: false,
KubeConfigPath: config.GetDefaultKubeConfigPath(),
PluginDir: tempDir,
PluginDir: devPluginDir,
UserPluginDir: userPluginDir,
KubeConfigStore: kubeConfigStore,
},
cache: cache,
Expand Down
3 changes: 2 additions & 1 deletion backend/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func buildHeadlampCFG(conf *config.Config, kubeConfigStore kubeconfig.ContextSto
StaticDir: conf.StaticDir,
Insecure: conf.InsecureSsl,
PluginDir: conf.PluginsDir,
UserPluginDir: conf.UserPluginsDir,
EnableHelm: conf.EnableHelm,
EnableDynamicClusters: conf.EnableDynamicClusters,
WatchPluginsChanges: conf.WatchPluginsChanges,
Expand Down Expand Up @@ -237,7 +238,7 @@ func runListPlugins() {
os.Exit(1)
}

if err := plugins.ListPlugins(conf.StaticDir, conf.PluginsDir); err != nil {
if err := plugins.ListPlugins(conf.StaticDir, conf.UserPluginsDir, conf.PluginsDir); err != nil {
logger.Log(logger.LevelError, nil, err, "listing plugins")
}
}
Loading
Loading