Repository: Lichtblick-Suite/lichtblick
Pull request #389: Feature/improve extensions handling
Author: login: luluiz
Created: 2025-02-28T12:15:52Z | Merged: 2025-03-18T10:39:47Z

**User-Facing Changes**
<!-- will be used as a changelog entry -->

**Description**


<!-- link relevant GitHub issues -->
<!-- add `docs` label if this PR requires documentation updates -->
<!-- add relevant metric tracking for experimental / new features -->

**Checklist**

- [x] The web version was tested and it is running ok
- [x] The desktop version was tested and it is running ok
- [x] This change is covered by unit tests
- [x] Files constants.ts, types.ts and *.style.ts have been checked and relevant code snippets have been relocated



Diff:
diff --git a/desktop/integration-test/uninstall-extension.test.ts b/desktop/integration-test/uninstall-extension.test.ts
index d0027e64dc..2e4506b80b 100644
--- a/desktop/integration-test/uninstall-extension.test.ts
+++ b/desktop/integration-test/uninstall-extension.test.ts
@@ -26,7 +26,7 @@ describe("Uninstall extension", () => {
     await app.renderer.getByTestId("DataSourceDialog").getByTestId("CloseIcon").click();
 
     await app.renderer.getByTestId("PersonIcon").click();
-    await app.renderer.getByText("Extensions").click();
+    await app.renderer.getByRole("menuitem", { name: "Extensions" }).click();
     const searchBar = app.renderer.getByPlaceholder("Search Extensions...");
     await searchBar.fill("turtlesim");
     const extensionListItem = app.renderer
diff --git a/packages/suite-base/src/PanelAPI/useConfigById.ts b/packages/suite-base/src/PanelAPI/useConfigById.ts
index e3d78ea594..712dff6525 100644
--- a/packages/suite-base/src/PanelAPI/useConfigById.ts
+++ b/packages/suite-base/src/PanelAPI/useConfigById.ts
@@ -9,8 +9,9 @@ import * as _ from "lodash-es";
 import { useCallback, useMemo } from "react";
 import { DeepPartial } from "ts-essentials";
 
-import { PanelSettings, Topic } from "@lichtblick/suite";
+import { Topic } from "@lichtblick/suite";
 import { useMessagePipeline } from "@lichtblick/suite-base/components/MessagePipeline";
+import { ExtensionSettings } from "@lichtblick/suite-base/components/PanelSettings/types";
 import {
   LayoutState,
   useCurrentLayoutActions,
@@ -31,11 +32,7 @@ import { getPanelTypeFromId } from "../util/layout";
  */
 export default function useConfigById<Config extends Record<string, unknown>>(
   panelId: string | undefined,
-): [
-  Config | undefined,
-  SaveConfig<Config>,
-  Record<string, Record<string, PanelSettings<unknown>>>,
-] {
+): [Config | undefined, SaveConfig<Config>, ExtensionSettings] {
   const { getCurrentLayoutState, savePanelConfigs } = useCurrentLayoutActions();
   const extensionSettings = useExtensionCatalog(getExtensionPanelSettings);
   const sortedTopics = useMessagePipeline((state) => state.sortedTopics);
diff --git a/packages/suite-base/src/Workspace.tsx b/packages/suite-base/src/Workspace.tsx
index c400a5fab1..61fb3d84c6 100644
--- a/packages/suite-base/src/Workspace.tsx
+++ b/packages/suite-base/src/Workspace.tsx
@@ -229,67 +229,46 @@ function WorkspaceContent(props: WorkspaceProps): React.JSX.Element {
 
   const { enqueueSnackbar } = useSnackbar();
 
-  const installExtension = useExtensionCatalog((state) => state.installExtension);
+  const installExtensions = useExtensionCatalog((state) => state.installExtensions);
 
-  const openHandles = useCallback(
-    async (
-      handles: FileSystemFileHandle[] /* foxglove-depcheck-used: @types/wicg-file-system-access */,
-    ) => {
-      log.debug("open handles", handles);
-      const files = await Promise.all(handles.map(async (handle) => await handle.getFile()));
-
-      for (const file of files) {
-        if (file.name.endsWith(".foxe")) {
-          // Extension installation
-          try {
-            const arrayBuffer = await file.arrayBuffer();
-            const data = new Uint8Array(arrayBuffer);
-            const extension = await installExtension("local", data);
-            enqueueSnackbar(`Installed extension ${extension.id}`, { variant: "success" });
-          } catch (e: unknown) {
-            const err = e as Error;
-            log.error(err);
-            enqueueSnackbar(`Failed to install extension ${file.name}: ${err.message}`, {
-              variant: "error",
-            });
-          }
-        }
-      }
-
-      // Look for a source that supports the file extensions
-      const matchedSource = availableSources.find((source) => {
-        const ext = extname(files[0]!.name);
-        return source.supportedFileTypes?.includes(ext);
-      });
-      if (matchedSource) {
-        selectSource(matchedSource.id, { type: "file", handles });
+  const handleFiles = useCallback(
+    async (files: File[]) => {
+      if (files.length === 0) {
+        return;
       }
-    },
-    [availableSources, enqueueSnackbar, installExtension, selectSource],
-  );
 
-  const openFiles = useCallback(
-    async (files: File[]) => {
       const otherFiles: File[] = [];
       log.debug("open files", files);
 
+      const data: Uint8Array[] = [];
       for (const file of files) {
-        if (file.name.endsWith(".foxe")) {
-          // Extension installation
-          try {
+        try {
+          if (file.name.endsWith(".foxe")) {
             const arrayBuffer = await file.arrayBuffer();
-            const data = new Uint8Array(arrayBuffer);
-            const extension = await installExtension("local", data);
-            enqueueSnackbar(`Installed extension ${extension.id}`, { variant: "success" });
-          } catch (err: unknown) {
-            log.error(err);
-            enqueueSnackbar(`Failed to install extension ${file.name}: ${(err as Error).message}`, {
-              variant: "error",
-            });
+            data.push(new Uint8Array(arrayBuffer));
+          } else {
+            otherFiles.push(file);
           }
+        } catch (error) {
+          console.error(`Error loading foxe file ${file.name}`, error);
+        }
+      }
+
+      try {
+        enqueueSnackbar(`Installing ${data.length} extensions`, { variant: "info" });
+        const result = await installExtensions("local", data);
+        const installed = result.filter(({ success }) => success);
+        const progressText = `${installed.length}/${result.length}`;
+
+        if (installed.length === result.length) {
+          enqueueSnackbar(`Installed all extensions (${progressText})`, { variant: "success" });
         } else {
-          otherFiles.push(file);
+          enqueueSnackbar(`Installed ${progressText} extensions.`, { variant: "warning" });
         }
+      } catch (error) {
+        enqueueSnackbar(`An error occurred during extension installation: ${error.message}`, {
+          variant: "error",
+        });
       }
 
       if (otherFiles.length > 0) {
@@ -308,29 +287,33 @@ function WorkspaceContent(props: WorkspaceProps): React.JSX.Element {
         }
       }
     },
-    [availableSources, enqueueSnackbar, installExtension, selectSource],
+    [availableSources, enqueueSnackbar, installExtensions, selectSource],
   );
 
   // files the main thread told us to open
   const filesToOpen = useElectronFilesToOpen();
   useEffect(() => {
     if (filesToOpen) {
-      void openFiles(Array.from(filesToOpen));
+      void handleFiles(Array.from(filesToOpen));
     }
-  }, [filesToOpen, openFiles]);
+  }, [filesToOpen, handleFiles]);
 
   const dropHandler = useCallback(
-    (event: { files?: File[]; handles?: FileSystemFileHandle[] }) => {
-      // When selecting sources with handles we can only select with a single handle since we haven't
-      // written the code to store multiple handles for recents. When there are multiple handles, we
-      // fall back to opening regular files.
-      if (event.handles) {
-        void openHandles(event.handles);
-      } else if (event.files) {
-        void openFiles(event.files);
+    async ({ files, handles }: { files?: File[]; handles?: FileSystemFileHandle[] }) => {
+      const filesArray: File[] = [];
+
+      if (handles?.length === 1) {
+        const fileHandle = handles[0];
+        if (fileHandle) {
+          filesArray.push(await fileHandle.getFile());
+        }
+      } else if (files?.length != undefined) {
+        filesArray.push(...files);
       }
+
+      void handleFiles(filesArray);
     },
-    [openFiles, openHandles],
+    [handleFiles],
   );
 
   // Since the _component_ field of a sidebar item entry is a component and accepts no additional
diff --git a/packages/suite-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx b/packages/suite-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx
index 1ca5ce9434..c3ccce4637 100644
--- a/packages/suite-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx
+++ b/packages/suite-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx
@@ -49,6 +49,7 @@ const marketplaceExtensions: ExtensionInfo[] = [
 
 const MockExtensionLoader: ExtensionLoader = {
   namespace: "local",
+  getExtension: async () => installedExtensions[0],
   getExtensions: async () => installedExtensions,
   loadExtension: async (_id: string) => "",
   installExtension: async (_foxeFileData: Uint8Array) => {
diff --git a/packages/suite-base/src/components/ExtensionDetails.stories.tsx b/packages/suite-base/src/components/ExtensionDetails.stories.tsx
index 92823b20ae..f082533060 100644
--- a/packages/suite-base/src/components/ExtensionDetails.stories.tsx
+++ b/packages/suite-base/src/components/ExtensionDetails.stories.tsx
@@ -34,6 +34,7 @@ export default {
 
 const MockExtensionLoader: ExtensionLoader = {
   namespace: "local",
+  getExtension: async () => undefined,
   getExtensions: async () => [],
   loadExtension: async (_id: string) => "",
   installExtension: async (_foxeFileData: Uint8Array) => {
diff --git a/packages/suite-base/src/components/ExtensionDetails.test.tsx b/packages/suite-base/src/components/ExtensionDetails.test.tsx
index 5e272a22a9..5bdaef8d7a 100644
--- a/packages/suite-base/src/components/ExtensionDetails.test.tsx
+++ b/packages/suite-base/src/components/ExtensionDetails.test.tsx
@@ -41,7 +41,7 @@ describe("ExtensionDetails Component", () => {
   const mockEnqueueSnackbar = jest.fn();
   const mockLogEvent = jest.fn();
   const mockDownloadExtension = jest.fn();
-  const mockInstallExtension = jest.fn();
+  const mockInstallExtensions = jest.fn();
   const mockUninstallExtension = jest.fn();
   const mockGetMarkdown = jest.fn();
 
@@ -66,7 +66,7 @@ describe("ExtensionDetails Component", () => {
     (useExtensionCatalog as jest.Mock).mockImplementation((selector) => {
       const mockExtensionCatalog = {
         downloadExtension: mockDownloadExtension,
-        installExtension: mockInstallExtension,
+        installExtensions: mockInstallExtensions,
         uninstallExtension: mockUninstallExtension,
         refreshExtensions: jest.fn(),
         installedExtensions: [],
@@ -112,7 +112,7 @@ describe("ExtensionDetails Component", () => {
       (isDesktopApp as jest.Mock).mockReturnValue(true);
 
       mockDownloadExtension.mockResolvedValue(new Uint8Array());
-      mockInstallExtension.mockResolvedValue({});
+      mockInstallExtensions.mockResolvedValue({});
 
       render(<ExtensionDetails extension={mockExtension} onClose={() => {}} installed={false} />);
 
@@ -121,7 +121,7 @@ describe("ExtensionDetails Component", () => {
 
       await waitFor(() => {
         expect(mockDownloadExtension).toHaveBeenCalledWith(mockExtension.foxe);
-        expect(mockInstallExtension).toHaveBeenCalledWith("local", expect.any(Uint8Array));
+        expect(mockInstallExtensions).toHaveBeenCalledWith("local", [expect.any(Uint8Array)]);
         expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
           `${mockExtension.name} installed successfully`,
           { variant: "success" },
@@ -146,7 +146,7 @@ describe("ExtensionDetails Component", () => {
           { variant: "error" },
         );
         expect(mockDownloadExtension).not.toHaveBeenCalled();
-        expect(mockInstallExtension).not.toHaveBeenCalled();
+        expect(mockInstallExtensions).not.toHaveBeenCalled();
       });
     });
 
diff --git a/packages/suite-base/src/components/ExtensionDetails.tsx b/packages/suite-base/src/components/ExtensionDetails.tsx
index 7821d5a2d6..f8df353f13 100644
--- a/packages/suite-base/src/components/ExtensionDetails.tsx
+++ b/packages/suite-base/src/components/ExtensionDetails.tsx
@@ -63,7 +63,7 @@ export function ExtensionDetails({ extension, onClose, installed }: Props): Reac
   const [activeTab, setActiveTab] = useState<number>(0);
   const isMounted = useMountedState();
   const downloadExtension = useExtensionCatalog((state) => state.downloadExtension);
-  const installExtension = useExtensionCatalog((state) => state.installExtension);
+  const installExtensions = useExtensionCatalog((state) => state.installExtensions);
   const uninstallExtension = useExtensionCatalog((state) => state.uninstallExtension);
   const marketplace = useExtensionMarketplace();
   const { enqueueSnackbar } = useSnackbar();
@@ -105,7 +105,7 @@ export function ExtensionDetails({ extension, onClose, installed }: Props): Reac
       }
       setOperationStatus(OperationStatus.INSTALLING);
       const data = await downloadExtension(url);
-      await installExtension("local", data);
+      await installExtensions("local", [data]);
       enqueueSnackbar(`${extension.name} installed successfully`, { variant: "success" });
       if (isMounted()) {
         setIsInstalled(true);
@@ -125,7 +125,7 @@ export function ExtensionDetails({ extension, onClose, installed }: Props): Reac
     enqueueSnackbar,
     extension.foxe,
     extension.id,
-    installExtension,
+    installExtensions,
     isMounted,
     extension.name,
   ]);
diff --git a/packages/suite-base/src/components/ExtensionsSettings/index.stories.tsx b/packages/suite-base/src/components/ExtensionsSettings/index.stories.tsx
index 12e482f4ae..d4ef7bfddc 100644
--- a/packages/suite-base/src/components/ExtensionsSettings/index.stories.tsx
+++ b/packages/suite-base/src/components/ExtensionsSettings/index.stories.tsx
@@ -77,6 +77,7 @@ const marketplaceExtensions: ExtensionInfo[] = [
 
 const MockExtensionLoader: ExtensionLoader = {
   namespace: "local",
+  getExtension: async () => installedExtensions[0],
   getExtensions: async () => installedExtensions,
   loadExtension: async (_id: string) => "",
   installExtension: async (_foxeFileData: Uint8Array) => {
diff --git a/packages/suite-base/src/components/PanelSettings/types.ts b/packages/suite-base/src/components/PanelSettings/types.ts
index a13a113c19..4606d2dce2 100644
--- a/packages/suite-base/src/components/PanelSettings/types.ts
+++ b/packages/suite-base/src/components/PanelSettings/types.ts
@@ -7,9 +7,11 @@ import { PanelSettings } from "@lichtblick/suite";
 import { MessagePipelineContext } from "@lichtblick/suite-base/components/MessagePipeline";
 import { PanelStateStore } from "@lichtblick/suite-base/context/PanelStateContext";
 
+export type ExtensionSettings = Record<string, Record<string, PanelSettings<unknown>>>;
+
 export type BuildSettingsTreeProps = {
   config: Record<string, unknown> | undefined;
-  extensionSettings: Record<string, Record<string, PanelSettings<unknown>>>;
+  extensionSettings: ExtensionSettings;
   messagePipelineState: () => MessagePipelineContext;
   panelType: string | undefined;
   selectedPanelId: string | undefined;
diff --git a/packages/suite-base/src/context/ExtensionCatalogContext.ts b/packages/suite-base/src/context/ExtensionCatalogContext.ts
index c84827ea88..5214b8d283 100644
--- a/packages/suite-base/src/context/ExtensionCatalogContext.ts
+++ b/packages/suite-base/src/context/ExtensionCatalogContext.ts
@@ -15,33 +15,56 @@ import {
   PanelSettings,
   RegisterMessageConverterArgs,
 } from "@lichtblick/suite";
+import { ExtensionSettings } from "@lichtblick/suite-base/components/PanelSettings/types";
 import { TopicAliasFunctions } from "@lichtblick/suite-base/players/TopicAliasingPlayer/TopicAliasingPlayer";
 import { ExtensionInfo, ExtensionNamespace } from "@lichtblick/suite-base/types/Extensions";
 
 export type RegisteredPanel = {
+  extensionId: string;
   extensionName: string;
   extensionNamespace?: ExtensionNamespace;
   registration: ExtensionPanelRegistration;
 };
 
+export type InstallExtensionsResult = {
+  success: boolean;
+  info?: ExtensionInfo;
+  error?: unknown;
+};
+
 export type ExtensionCatalog = Immutable<{
   downloadExtension: (url: string) => Promise<Uint8Array>;
-  installExtension: (
+  installExtensions: (
     namespace: ExtensionNamespace,
-    foxeFileData: Uint8Array,
-  ) => Promise<ExtensionInfo>;
-  refreshExtensions: () => Promise<void>;
+    data: Uint8Array[],
+  ) => Promise<InstallExtensionsResult[]>;
+  isExtensionInstalled: (extensionId: string) => boolean;
+  markExtensionAsInstalled: (extensionId: string) => void;
+  mergeState: (info: ExtensionInfo, contributionPoints: ContributionPoints) => void;
+  refreshAllExtensions: () => Promise<void>;
   uninstallExtension: (namespace: ExtensionNamespace, id: string) => Promise<void>;
+  unMarkExtensionAsInstalled: (extensionId: string) => void;
 
+  loadedExtensions: Set<string>;
   installedExtensions: undefined | ExtensionInfo[];
   installedPanels: undefined | Record<string, RegisteredPanel>;
-  installedMessageConverters:
-    | undefined
-    | Omit<RegisterMessageConverterArgs<unknown>, "panelSettings">[];
+  installedMessageConverters: undefined | Omit<MessageConverter, "panelSettings">[];
   installedTopicAliasFunctions: undefined | TopicAliasFunctions;
-  panelSettings: undefined | Record<string, Record<string, PanelSettings<unknown>>>;
+  panelSettings: undefined | ExtensionSettings;
 }>;
 
+export type MessageConverter = RegisterMessageConverterArgs<unknown> & {
+  extensionNamespace?: ExtensionNamespace;
+  extensionId?: string;
+};
+
+export type ContributionPoints = {
+  panels: Record<string, RegisteredPanel>;
+  messageConverters: MessageConverter[];
+  topicAliasFunctions: TopicAliasFunctions;
+  panelSettings: ExtensionSettings;
+};
+
 export const ExtensionCatalogContext = createContext<undefined | StoreApi<ExtensionCatalog>>(
   undefined,
 );
diff --git a/packages/suite-base/src/providers/ExtensionCatalogProvider.test.tsx b/packages/suite-base/src/providers/ExtensionCatalogProvider.test.tsx
index a3a5c44680..1111943b74 100644
--- a/packages/suite-base/src/providers/ExtensionCatalogProvider.test.tsx
+++ b/packages/suite-base/src/providers/ExtensionCatalogProvider.test.tsx
@@ -5,120 +5,90 @@
 // This Source Code Form is subject to the terms of the Mozilla Public
 // License, v2.0. If a copy of the MPL was not distributed with this
 // file, You can obtain one at http://mozilla.org/MPL/2.0/
-
-import { render, renderHook, waitFor } from "@testing-library/react";
+import { act, render, renderHook, waitFor } from "@testing-library/react";
 import { useEffect } from "react";
 
-import { PanelSettings } from "@lichtblick/suite";
+import { ExtensionPanelRegistration, PanelSettings } from "@lichtblick/suite";
 import { useConfigById } from "@lichtblick/suite-base/PanelAPI";
 import Panel from "@lichtblick/suite-base/components/Panel";
-import { useExtensionCatalog } from "@lichtblick/suite-base/context/ExtensionCatalogContext";
+import {
+  ContributionPoints,
+  MessageConverter,
+  useExtensionCatalog,
+} from "@lichtblick/suite-base/context/ExtensionCatalogContext";
+import { TopicAliasFunctions } from "@lichtblick/suite-base/players/TopicAliasingPlayer/StateProcessorFactory";
 import { ExtensionLoader } from "@lichtblick/suite-base/services/ExtensionLoader";
 import PanelSetup from "@lichtblick/suite-base/stories/PanelSetup";
-import { ExtensionInfo } from "@lichtblick/suite-base/types/Extensions";
+import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder";
+import ExtensionBuilder from "@lichtblick/suite-base/testing/builders/ExtensionBuilder";
+import { ExtensionInfo, ExtensionNamespace } from "@lichtblick/suite-base/types/Extensions";
 
 import ExtensionCatalogProvider from "./ExtensionCatalogProvider";
 
-function fakeExtension(overrides: Partial<ExtensionInfo>): ExtensionInfo {
-  return {
-    id: "id",
-    description: "description",
-    displayName: "display name",
-    homepage: "homepage",
-    keywords: ["keyword1", "keyword2"],
-    license: "license",
-    name: "name",
-    namespace: "local",
-    publisher: "publisher",
-    qualifiedName: "qualified name",
-    version: "1",
-    ...overrides,
-  };
-}
-
 describe("ExtensionCatalogProvider", () => {
-  it("should load an extension from the loaders", async () => {
-    const source = `
-        module.exports = { activate: function() { return 1; } }
-    `;
-
-    const loadExtension = jest.fn().mockResolvedValue(source);
-    const mockPrivateLoader: ExtensionLoader = {
-      namespace: "org",
-      getExtensions: jest
-        .fn()
-        .mockResolvedValue([fakeExtension({ namespace: "org", name: "sample", version: "1" })]),
+  function setup({ loadersOverride }: { loadersOverride?: ExtensionLoader[] } = {}) {
+    const extensionInfo: ExtensionInfo = ExtensionBuilder.extension();
+    const extensions: ExtensionInfo[] = [extensionInfo];
+
+    const loadExtension = jest
+      .fn()
+      .mockResolvedValue(`module.exports = { activate: function() { return 1; } }`);
+    const loaderDefault: ExtensionLoader = {
+      namespace: extensionInfo.namespace!,
+      getExtension: jest.fn().mockResolvedValue(extensionInfo),
+      getExtensions: jest.fn().mockResolvedValue(extensions),
+      installExtension: jest.fn().mockResolvedValue(extensionInfo),
       loadExtension,
-      installExtension: jest.fn(),
       uninstallExtension: jest.fn(),
     };
+    const loaders = loadersOverride ?? [loaderDefault];
+
+    return {
+      ...renderHook(() => useExtensionCatalog((state) => state), {
+        initialProps: {},
+        wrapper: ({ children }) => (
+          <ExtensionCatalogProvider loaders={loaders}>{children}</ExtensionCatalogProvider>
+        ),
+      }),
+      extensionInfo,
+      loaders,
+      loadExtension,
+    };
+  }
 
-    const { result } = renderHook(() => useExtensionCatalog((state) => state), {
-      initialProps: {},
-      wrapper: ({ children }) => (
-        <ExtensionCatalogProvider loaders={[mockPrivateLoader]}>
-          {children}
-        </ExtensionCatalogProvider>
-      ),
-    });
+  it("should load an extension from the loaders", async () => {
+    const { loadExtension, result, extensionInfo } = setup();
 
     await waitFor(() => {
       expect(loadExtension).toHaveBeenCalledTimes(1);
     });
-    expect(result.current.installedExtensions).toEqual([
-      {
-        description: "description",
-        displayName: "display name",
-        homepage: "homepage",
-        id: "id",
-        keywords: ["keyword1", "keyword2"],
-        license: "license",
-        name: "sample",
-        namespace: "org",
-        publisher: "publisher",
-        qualifiedName: "qualified name",
-        version: "1",
-      },
-    ]);
+    expect(result.current.installedExtensions).toEqual([extensionInfo]);
   });
 
   it("handles extensions with the same id in different loaders", async () => {
-    const source1 = `
-        module.exports = { activate: function() { return 1; } }
-    `;
-    const source2 = `
-        module.exports = { activate: function() { return 2; } }
-    `;
-
+    const source1 = `module.exports = { activate: function() { return 1; } }`;
+    const source2 = `module.exports = { activate: function() { return 2; } }`;
+    const extension1 = ExtensionBuilder.extension({ namespace: "org" });
+    const extension2 = ExtensionBuilder.extension({ namespace: "local" });
     const loadExtension1 = jest.fn().mockResolvedValue(source1);
     const loadExtension2 = jest.fn().mockResolvedValue(source2);
-    const mockPrivateLoader1: ExtensionLoader = {
-      namespace: "org",
-      getExtensions: jest
-        .fn()
-        .mockResolvedValue([fakeExtension({ namespace: "org", name: "sample", version: "1" })]),
+    const loader1: ExtensionLoader = {
+      namespace: extension1.namespace!,
+      getExtension: jest.fn(),
+      getExtensions: jest.fn().mockResolvedValue([extension1]),
       loadExtension: loadExtension1,
       installExtension: jest.fn(),
       uninstallExtension: jest.fn(),
     };
-    const mockPrivateLoader2: ExtensionLoader = {
-      namespace: "org",
-      getExtensions: jest
-        .fn()
-        .mockResolvedValue([fakeExtension({ namespace: "local", name: "sample", version: "2" })]),
+    const loader2: ExtensionLoader = {
+      namespace: extension2.namespace!,
+      getExtension: jest.fn(),
+      getExtensions: jest.fn().mockResolvedValue([extension2]),
       loadExtension: loadExtension2,
       installExtension: jest.fn(),
       uninstallExtension: jest.fn(),
     };
-
-    const { result } = renderHook(() => useExtensionCatalog((state) => state), {
-      initialProps: {},
-      wrapper: ({ children }) => (
-        <ExtensionCatalogProvider loaders={[mockPrivateLoader1, mockPrivateLoader2]}>
-          {children}
-        </ExtensionCatalogProvider>
-      ),
-    });
+    const { result } = setup({ loadersOverride: [loader1, loader2] });
 
     await waitFor(() => {
       expect(loadExtension1).toHaveBeenCalledTimes(1);
@@ -126,34 +96,7 @@ describe("ExtensionCatalogProvider", () => {
     await waitFor(() => {
       expect(loadExtension2).toHaveBeenCalledTimes(1);
     });
-    expect(result.current.installedExtensions).toEqual([
-      {
-        description: "description",
-        displayName: "display name",
-        homepage: "homepage",
-        id: "id",
-        keywords: ["keyword1", "keyword2"],
-        license: "license",
-        name: "sample",
-        namespace: "org",
-        publisher: "publisher",
-        qualifiedName: "qualified name",
-        version: "1",
-      },
-      {
-        description: "description",
-        displayName: "display name",
-        homepage: "homepage",
-        id: "id",
-        keywords: ["keyword1", "keyword2"],
-        license: "license",
-        name: "sample",
-        namespace: "local",
-        publisher: "publisher",
-        qualifiedName: "qualified name",
-        version: "2",
-      },
-    ]);
+    expect(result.current.installedExtensions).toEqual([extension1, extension2]);
   });
 
   it("should register a message converter", async () => {
@@ -168,36 +111,29 @@ describe("ExtensionCatalogProvider", () => {
             }
         }
     `;
-
     const loadExtension = jest.fn().mockResolvedValue(source);
-    const mockPrivateLoader: ExtensionLoader = {
-      namespace: "org",
-      getExtensions: jest
-        .fn()
-        .mockResolvedValue([fakeExtension({ namespace: "org", name: "sample", version: "1" })]),
+    const extension = ExtensionBuilder.extension();
+    const loader: ExtensionLoader = {
+      namespace: extension.namespace!,
+      getExtension: jest.fn(),
+      getExtensions: jest.fn().mockResolvedValue([extension]),
       loadExtension,
       installExtension: jest.fn(),
       uninstallExtension: jest.fn(),
     };
 
-    const { result } = renderHook(() => useExtensionCatalog((state) => state), {
-      initialProps: {},
-      wrapper: ({ children }) => (
-        <ExtensionCatalogProvider loaders={[mockPrivateLoader]}>
-          {children}
-        </ExtensionCatalogProvider>
-      ),
-    });
+    const { result } = setup({ loadersOverride: [loader] });
 
     await waitFor(() => {
       expect(loadExtension).toHaveBeenCalledTimes(1);
     });
     expect(result.current.installedMessageConverters).toEqual([
       {
+        converter: expect.any(Function),
+        extensionId: expect.any(String),
+        extensionNamespace: extension.namespace,
         fromSchemaName: "from.Schema",
         toSchemaName: "to.Schema",
-        converter: expect.any(Function),
-        extensionNamespace: "org",
       },
     ]);
   });
@@ -231,26 +167,18 @@ describe("ExtensionCatalogProvider", () => {
             }
         }
     `;
-
+    const extension = ExtensionBuilder.extension();
     const loadExtension = jest.fn().mockResolvedValue(source);
-    const mockPrivateLoader: ExtensionLoader = {
-      namespace: "org",
-      getExtensions: jest
-        .fn()
-        .mockResolvedValue([fakeExtension({ namespace: "org", name: "sample", version: "1" })]),
-      loadExtension,
+    const loader: ExtensionLoader = {
+      namespace: extension.namespace!,
+      getExtension: jest.fn(),
+      getExtensions: jest.fn().mockResolvedValue([extension]),
       installExtension: jest.fn(),
+      loadExtension,
       uninstallExtension: jest.fn(),
     };
 
-    const { result } = renderHook(() => useExtensionCatalog((state) => state), {
-      initialProps: {},
-      wrapper: ({ children }) => (
-        <ExtensionCatalogProvider loaders={[mockPrivateLoader]}>
-          {children}
-        </ExtensionCatalogProvider>
-      ),
-    });
+    const { result } = setup({ loadersOverride: [loader] });
 
     await waitFor(() => {
       expect(loadExtension).toHaveBeenCalledTimes(1);
@@ -276,13 +204,12 @@ describe("ExtensionCatalogProvider", () => {
             }
         }
     `;
-
     const loadExtension = jest.fn().mockResolvedValue(source);
-    const mockPrivateLoader: ExtensionLoader = {
-      namespace: "org",
-      getExtensions: jest
-        .fn()
-        .mockResolvedValue([fakeExtension({ namespace: "org", name: "sample", version: "1" })]),
+    const extension = ExtensionBuilder.extension();
+    const loader: ExtensionLoader = {
+      namespace: extension.namespace!,
+      getExtension: jest.fn(),
+      getExtensions: jest.fn().mockResolvedValue([extension]),
       loadExtension,
       installExtension: jest.fn(),
       uninstallExtension: jest.fn(),
@@ -291,9 +218,7 @@ describe("ExtensionCatalogProvider", () => {
     const { result } = renderHook(() => useExtensionCatalog((state) => state), {
       initialProps: {},
       wrapper: ({ children }) => (
-        <ExtensionCatalogProvider loaders={[mockPrivateLoader]}>
-          {children}
-        </ExtensionCatalogProvider>
+        <ExtensionCatalogProvider loaders={[loader]}>{children}</ExtensionCatalogProvider>
       ),
     });
 
@@ -301,7 +226,7 @@ describe("ExtensionCatalogProvider", () => {
       expect(loadExtension).toHaveBeenCalledTimes(1);
     });
     expect(result.current.installedTopicAliasFunctions).toEqual([
-      { extensionId: "id", aliasFunction: expect.any(Function) },
+      { extensionId: extension.id, aliasFunction: expect.any(Function) },
     ]);
   });
 
@@ -369,4 +294,155 @@ describe("ExtensionCatalogProvider", () => {
 
     (console.error as jest.Mock).mockRestore();
   });
+
+  describe("isExtensionInstalled", () => {
+    it("should check if an extension is installed", async () => {
+      const { loadExtension, result, extensionInfo } = setup();
+
+      await waitFor(() => {
+        expect(loadExtension).toHaveBeenCalled();
+      });
+
+      expect(result.current.isExtensionInstalled(extensionInfo.id)).toBe(true);
+    });
+  });
+
+  describe("unMarkExtensionAsInstalled", () => {
+    it("should unmark an extension as installed", async () => {
+      const { loadExtension, result, extensionInfo } = setup();
+
+      await waitFor(() => {
+        expect(loadExtension).toHaveBeenCalled();
+      });
+
+      expect(result.current.isExtensionInstalled(extensionInfo.id)).toBe(true);
+      act(() => {
+        result.current.unMarkExtensionAsInstalled(extensionInfo.id);
+      });
+      expect(result.current.isExtensionInstalled(extensionInfo.id)).toBe(false);
+      expect(result.current.loadedExtensions.size).toBe(0);
+    });
+  });
+
+  describe("installExtensions", () => {
+    it("should install an extension", async () => {
+      const { result, extensionInfo } = setup();
+
+      await act(async () => {
+        const response = await result.current.installExtensions(extensionInfo.namespace!, [
+          new Uint8Array(),
+        ]);
+        expect(response.length).toBe(1);
+        expect(response[0]?.success).toBe(true);
+        expect(response[0]?.info).toEqual(extensionInfo);
+      });
+      expect(result.current.isExtensionInstalled(extensionInfo.id)).toBe(true);
+    });
+
+    it("should throw an error when install with no registered loader to the namespace", async () => {
+      const invalidNamespace = BasicBuilder.string() as ExtensionNamespace;
+      const { result } = setup();
+
+      await expect(
+        act(async () => {
+          await result.current.installExtensions(invalidNamespace, [new Uint8Array()]);
+        }),
+      ).rejects.toThrow(`No extension loader found for namespace ${invalidNamespace}`);
+    });
+  });
+
+  describe("uninstallExtension", () => {
+    it("should uninstall an extension", async () => {
+      const { result, extensionInfo, loaders } = setup();
+      // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
+      const namespace: ExtensionNamespace = loaders[0]?.namespace!;
+
+      await act(async () => {
+        await result.current.installExtensions(namespace, [new Uint8Array()]);
+        await result.current.uninstallExtension(namespace, extensionInfo.id);
+      });
+
+      expect(result.current.isExtensionInstalled(extensionInfo.id)).toBe(false);
+      expect(result.current.installedExtensions?.length).toBe(0);
+      expect(result.current.installedPanels).toEqual({});
+      expect(result.current.installedMessageConverters?.length).toBe(0);
+      expect(result.current.installedTopicAliasFunctions?.length).toBe(0);
+    });
+
+    it("should throw an error when uninstall with no registered loader to the namespace", async () => {
+      const invalidNamespace = BasicBuilder.string() as ExtensionNamespace;
+      const { result } = setup();
+
+      await expect(
+        act(async () => {
+          await result.current.uninstallExtension(invalidNamespace, "");
+        }),
+      ).rejects.toThrow(`No extension loader found for namespace ${invalidNamespace}`);
+    });
+  });
+
+  describe("mergeState", () => {
+    it("should merge state correctly using mergeState", async () => {
+      const { result, extensionInfo } = setup();
+      const panelName = BasicBuilder.string();
+      const messageConverter: MessageConverter = {
+        fromSchemaName: BasicBuilder.string(),
+        toSchemaName: BasicBuilder.string(),
+        converter: jest.fn(),
+        extensionId: extensionInfo.id,
+        extensionNamespace: extensionInfo.namespace,
+      };
+      const topicAliasFunctions: TopicAliasFunctions = [
+        { extensionId: extensionInfo.id, aliasFunction: jest.fn() },
+      ];
+      const contributionPoints: ContributionPoints = {
+        messageConverters: [messageConverter],
+        topicAliasFunctions,
+        panelSettings: {
+          panelA: {
+            schemaA: {
+              defaultConfig: {},
+              handler: jest.fn(),
+              settings: jest.fn(),
+            },
+          },
+        },
+        panels: {
+          [panelName]: {
+            extensionId: extensionInfo.id,
+            extensionName: extensionInfo.qualifiedName,
+            extensionNamespace: extensionInfo.namespace,
+            registration: {} as ExtensionPanelRegistration,
+          },
+        },
+      };
+
+      await act(async () => {
+        await result.current.installExtensions(extensionInfo.namespace!, [new Uint8Array()]);
+      });
+
+      act(() => {
+        result.current.mergeState(extensionInfo, contributionPoints);
+      });
+
+      expect(result.current.installedExtensions).toContainEqual(
+        expect.objectContaining({ id: extensionInfo.id }),
+      );
+      expect(result.current.installedMessageConverters).toHaveLength(1);
+      expect(result.current.installedMessageConverters![0]).toEqual({
+        ...messageConverter,
+        converter: expect.any(Function),
+      });
+      expect(result.current.installedPanels).toEqual({ [panelName]: expect.any(Object) });
+      expect(result.current.installedPanels![panelName]).toMatchObject({
+        extensionId: extensionInfo.id,
+        extensionName: extensionInfo.qualifiedName,
+        extensionNamespace: extensionInfo.namespace,
+      });
+      expect(result.current.installedTopicAliasFunctions).toHaveLength(1);
+      expect(result.current.installedTopicAliasFunctions![0]).toMatchObject({
+        extensionId: extensionInfo.id,
+      });
+    });
+  });
 });
diff --git a/packages/suite-base/src/providers/ExtensionCatalogProvider.tsx b/packages/suite-base/src/providers/ExtensionCatalogProvider.tsx
index 78e884e9a0..89277cd067 100644
--- a/packages/suite-base/src/providers/ExtensionCatalogProvider.tsx
+++ b/packages/suite-base/src/providers/ExtensionCatalogProvider.tsx
@@ -7,219 +7,261 @@
 
 import * as _ from "lodash-es";
 import React, { PropsWithChildren, useEffect, useState } from "react";
-import ReactDOM from "react-dom";
 import { StoreApi, createStore } from "zustand";
 
 import Logger from "@lichtblick/log";
+import { RegisterMessageConverterArgs } from "@lichtblick/suite";
 import {
-  ExtensionContext,
-  ExtensionModule,
-  PanelSettings,
-  RegisterMessageConverterArgs,
-  TopicAliasFunction,
-} from "@lichtblick/suite";
-import {
+  ContributionPoints,
   ExtensionCatalog,
   ExtensionCatalogContext,
-  RegisteredPanel,
+  InstallExtensionsResult,
 } from "@lichtblick/suite-base/context/ExtensionCatalogContext";
-import { TopicAliasFunctions } from "@lichtblick/suite-base/players/TopicAliasingPlayer/aliasing";
+import { buildContributionPoints } from "@lichtblick/suite-base/providers/helpers/buildContributionPoints";
 import { ExtensionLoader } from "@lichtblick/suite-base/services/ExtensionLoader";
 import { ExtensionInfo, ExtensionNamespace } from "@lichtblick/suite-base/types/Extensions";
 
 const log = Logger.getLogger(__filename);
 
-type MessageConverter = RegisterMessageConverterArgs<unknown> & {
-  extensionNamespace?: ExtensionNamespace;
-};
-
-type ContributionPoints = {
-  panels: Record<string, RegisteredPanel>;
-  messageConverters: MessageConverter[];
-  topicAliasFunctions: TopicAliasFunctions;
-  panelSettings: Record<string, Record<string, PanelSettings<unknown>>>;
-};
-
-function activateExtension(
-  extension: ExtensionInfo,
-  unwrappedExtensionSource: string,
-): ContributionPoints {
-  // registered panels stored by their fully qualified id
-  // the fully qualified id is the extension name + panel name
-  const panels: Record<string, RegisteredPanel> = {};
-
-  const messageConverters: RegisterMessageConverterArgs<unknown>[] = [];
-
-  const panelSettings: Record<string, Record<string, PanelSettings<unknown>>> = {};
-
-  const topicAliasFunctions: ContributionPoints["topicAliasFunctions"] = [];
-
-  log.debug(`Activating extension ${extension.qualifiedName}`);
-
-  const module = { exports: {} };
-  const require = (name: string) => {
-    return { react: React, "react-dom": ReactDOM }[name];
-  };
-
-  const extensionMode =
-    process.env.NODE_ENV === "production"
-      ? "production"
-      : process.env.NODE_ENV === "test"
-        ? "test"
-        : "development";
-
-  const ctx: ExtensionContext = {
-    mode: extensionMode,
-
-    registerPanel: (params) => {
-      log.debug(`Extension ${extension.qualifiedName} registering panel: ${params.name}`);
-
-      const fullId = `${extension.qualifiedName}.${params.name}`;
-      if (panels[fullId]) {
-        log.warn(`Panel ${fullId} is already registered`);
-        return;
-      }
-
-      panels[fullId] = {
-        extensionName: extension.qualifiedName,
-        extensionNamespace: extension.namespace,
-        registration: params,
-      };
-    },
-
-    registerMessageConverter: <Src,>(args: RegisterMessageConverterArgs<Src>) => {
-      log.debug(
-        `Extension ${extension.qualifiedName} registering message converter from: ${args.fromSchemaName} to: ${args.toSchemaName}`,
-      );
-      messageConverters.push({
-        ...args,
-        extensionNamespace: extension.namespace,
-      } as MessageConverter);
-
-      const converterSettings = _.mapValues(args.panelSettings, (settings) => ({
-        [args.fromSchemaName]: settings,
-      }));
-
-      _.merge(panelSettings, converterSettings);
-    },
-
-    registerTopicAliases: (aliasFunction: TopicAliasFunction) => {
-      topicAliasFunctions.push({ aliasFunction, extensionId: extension.id });
-    },
-  };
-
-  try {
-    // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
-    const fn = new Function("module", "require", unwrappedExtensionSource);
-
-    // load the extension module exports
-    fn(module, require, {});
-    const wrappedExtensionModule = module.exports as ExtensionModule;
-
-    wrappedExtensionModule.activate(ctx);
-  } catch (err: unknown) {
-    log.error(err);
-  }
-
-  return {
-    panels,
-    messageConverters,
-    topicAliasFunctions,
-    panelSettings,
-  };
-}
+const REFRESH_EXTENSIONS_BATCH = 3;
+const INSTALL_EXTENSIONS_BATCH = 3;
 
 function createExtensionRegistryStore(
   loaders: readonly ExtensionLoader[],
   mockMessageConverters: readonly RegisterMessageConverterArgs<unknown>[] | undefined,
 ): StoreApi<ExtensionCatalog> {
-  return createStore((set, get) => ({
-    downloadExtension: async (url: string) => {
+  return createStore((set, get) => {
+    const isExtensionInstalled = (extensionId: string) => {
+      return get().loadedExtensions.has(extensionId);
+    };
+
+    const markExtensionAsInstalled = (extensionId: string) => {
+      const updatedExtensions = new Set(get().loadedExtensions);
+      updatedExtensions.add(extensionId);
+      set({ loadedExtensions: updatedExtensions });
+    };
+
+    const unMarkExtensionAsInstalled = (extensionId: string) => {
+      const updatedExtensions = new Set(get().loadedExtensions);
+      updatedExtensions.delete(extensionId);
+      set({ loadedExtensions: updatedExtensions });
+    };
+
+    const downloadExtension = async (url: string) => {
       const res = await fetch(url);
       return new Uint8Array(await res.arrayBuffer());
-    },
+    };
 
-    installExtension: async (namespace: ExtensionNamespace, foxeFileData: Uint8Array) => {
-      const namespacedLoader = loaders.find((loader) => loader.namespace === namespace);
-      if (namespacedLoader == undefined) {
-        throw new Error("No extension loader found for namespace " + namespace);
+    const installExtensions = async (namespace: ExtensionNamespace, data: Uint8Array[]) => {
+      const namespaceLoader = loaders.find((loader) => loader.namespace === namespace);
+      if (namespaceLoader == undefined) {
+        throw new Error(`No extension loader found for namespace ${namespace}`);
       }
-      const info = await namespacedLoader.installExtension(foxeFileData);
-      await get().refreshExtensions();
-      return info;
-    },
 
-    refreshExtensions: async () => {
+      const results: InstallExtensionsResult[] = [];
+      for (let i = 0; i < data.length; i += INSTALL_EXTENSIONS_BATCH) {
+        const chunk = data.slice(i, i + INSTALL_EXTENSIONS_BATCH);
+        const result = await promisesInBatch(chunk, namespaceLoader);
+        results.push(...result);
+      }
+      return results;
+    };
+
+    async function promisesInBatch(
+      batch: Uint8Array[],
+      loader: ExtensionLoader,
+    ): Promise<InstallExtensionsResult[]> {
+      return await Promise.all(
+        batch.map(async (extensionData: Uint8Array) => {
+          try {
+            const info = await loader.installExtension(extensionData);
+            const unwrappedExtensionSource = await loader.loadExtension(info.id);
+            const contributionPoints = buildContributionPoints(info, unwrappedExtensionSource);
+
+            get().mergeState(info, contributionPoints);
+            get().markExtensionAsInstalled(info.id);
+            return { success: true, info };
+          } catch (error) {
+            return { success: false, error };
+          }
+        }),
+      );
+    }
+
+    const mergeState = (
+      info: ExtensionInfo,
+      { messageConverters, panelSettings, panels, topicAliasFunctions }: ContributionPoints,
+    ) => {
+      set((state) => ({
+        installedExtensions: _.uniqBy([...(state.installedExtensions ?? []), info], "id"),
+        installedPanels: { ...state.installedPanels, ...panels },
+        installedMessageConverters: _.uniqBy(
+          [...state.installedMessageConverters!, ...messageConverters],
+          "extensionId",
+        ),
+        installedTopicAliasFunctions: _.uniqBy(
+          [...state.installedTopicAliasFunctions!, ...topicAliasFunctions],
+          "extensionId",
+        ),
+        panelSettings: { ...state.panelSettings, ...panelSettings },
+      }));
+    };
+
+    async function loadInBatch({
+      batch,
+      loader,
+      installedExtensions,
+      contributionPoints,
+    }: {
+      batch: ExtensionInfo[];
+      loader: ExtensionLoader;
+      installedExtensions: ExtensionInfo[];
+      contributionPoints: ContributionPoints;
+    }) {
+      await Promise.all(
+        batch.map(async (extension) => {
+          try {
+            installedExtensions.push(extension);
+
+            const { messageConverters, panelSettings, panels, topicAliasFunctions } =
+              contributionPoints;
+            const unwrappedExtensionSource = await loader.loadExtension(extension.id);
+            const newContributionPoints = buildContributionPoints(
+              extension,
+              unwrappedExtensionSource,
+            );
+
+            _.assign(panels, newContributionPoints.panels);
+            _.merge(panelSettings, newContributionPoints.panelSettings);
+            messageConverters.push(...newContributionPoints.messageConverters);
+            topicAliasFunctions.push(...newContributionPoints.topicAliasFunctions);
+
+            get().markExtensionAsInstalled(extension.id);
+          } catch (err) {
+            log.error(`Error loading extension ${extension.id}`, err);
+          }
+        }),
+      );
+    }
+
+    const refreshAllExtensions = async () => {
+      log.debug("Refreshing all extensions");
       if (loaders.length === 0) {
         return;
       }
 
       const start = performance.now();
-      const extensionList: ExtensionInfo[] = [];
-      const allContributionPoints: ContributionPoints = {
-        panels: {},
+      const installedExtensions: ExtensionInfo[] = [];
+      const contributionPoints: ContributionPoints = {
         messageConverters: [],
-        topicAliasFunctions: [],
+        panels: {},
         panelSettings: {},
+        topicAliasFunctions: [],
       };
-      for (const loader of loaders) {
+
+      const processLoader = async (loader: ExtensionLoader) => {
         try {
-          for (const extension of await loader.getExtensions()) {
-            try {
-              extensionList.push(extension);
-              const unwrappedExtensionSource = await loader.loadExtension(extension.id);
-              const contributionPoints = activateExtension(extension, unwrappedExtensionSource);
-              Object.assign(allContributionPoints.panels, contributionPoints.panels);
-              _.merge(allContributionPoints.panelSettings, contributionPoints.panelSettings);
-              allContributionPoints.messageConverters.push(...contributionPoints.messageConverters);
-              allContributionPoints.topicAliasFunctions.push(
-                ...contributionPoints.topicAliasFunctions,
-              );
-            } catch (err: unknown) {
-              log.error("Error loading extension", err);
-            }
+          const extensions = await loader.getExtensions();
+          const chunks = _.chunk(extensions, REFRESH_EXTENSIONS_BATCH);
+          for (const chunk of chunks) {
+            await loadInBatch({
+              batch: chunk,
+              contributionPoints,
+              installedExtensions,
+              loader,
+            });
           }
         } catch (err: unknown) {
           log.error("Error loading extension list", err);
         }
-      }
+      };
+      await Promise.all(loaders.map(processLoader));
+
       log.info(
-        `Loaded ${extensionList.length} extensions in ${(performance.now() - start).toFixed(1)}ms`,
+        `Loaded ${installedExtensions.length} extensions in ${(performance.now() - start).toFixed(1)}ms`,
       );
+
       set({
-        installedExtensions: extensionList,
-        installedPanels: allContributionPoints.panels,
-        installedMessageConverters: allContributionPoints.messageConverters,
-        installedTopicAliasFunctions: allContributionPoints.topicAliasFunctions,
-        panelSettings: allContributionPoints.panelSettings,
+        installedExtensions,
+        installedPanels: contributionPoints.panels,
+        installedMessageConverters: contributionPoints.messageConverters,
+        installedTopicAliasFunctions: contributionPoints.topicAliasFunctions,
+        panelSettings: contributionPoints.panelSettings,
       });
-    },
-
-    // If there are no loaders then we know there will not be any installed extensions
-    installedExtensions: loaders.length === 0 ? [] : undefined,
-
-    installedPanels: {},
+    };
+
+    function removeExtensionData({
+      id, // deleted extension id
+      state,
+    }: {
+      id: string;
+      state: Pick<
+        ExtensionCatalog,
+        | "installedExtensions"
+        | "installedPanels"
+        | "installedMessageConverters"
+        | "installedTopicAliasFunctions"
+      >;
+    }) {
+      const {
+        installedExtensions,
+        installedPanels,
+        installedMessageConverters,
+        installedTopicAliasFunctions,
+      } = state;
+
+      return {
+        installedExtensions: installedExtensions?.filter(
+          ({ id: extensionId }) => extensionId !== id,
+        ),
+        installedPanels: _.pickBy(installedPanels, ({ extensionId }) => extensionId !== id),
+        installedMessageConverters: installedMessageConverters?.filter(
+          ({ extensionId }) => extensionId !== id,
+        ),
+        installedTopicAliasFunctions: installedTopicAliasFunctions?.filter(
+          ({ extensionId }) => extensionId !== id,
+        ),
+      };
+    }
 
-    installedMessageConverters: mockMessageConverters ?? [],
+    const uninstallExtension = async (namespace: ExtensionNamespace, id: string) => {
+      const namespaceLoader = loaders.find((loader) => loader.namespace === namespace);
+      if (namespaceLoader == undefined) {
+        throw new Error("No extension loader found for namespace " + namespace);
+      }
 
-    installedTopicAliasFunctions: [],
+      const extension = await namespaceLoader.getExtension(id);
+      if (!extension) {
+        return;
+      }
 
-    panelSettings: _.merge(
-      {},
-      ...(mockMessageConverters ?? []).map(({ fromSchemaName, panelSettings }) =>
-        _.mapValues(panelSettings, (settings) => ({ [fromSchemaName]: settings })),
+      await namespaceLoader.uninstallExtension(extension.id);
+      set((state) => removeExtensionData({ id: extension.id, state }));
+      get().unMarkExtensionAsInstalled(id);
+    };
+
+    return {
+      downloadExtension,
+      installExtensions,
+      isExtensionInstalled,
+      markExtensionAsInstalled,
+      mergeState,
+      refreshAllExtensions,
+      uninstallExtension,
+      unMarkExtensionAsInstalled,
+      installedExtensions: loaders.length === 0 ? [] : undefined,
+      installedMessageConverters: mockMessageConverters ?? [],
+      installedPanels: {},
+      installedTopicAliasFunctions: [],
+      loadedExtensions: new Set<string>(),
+      panelSettings: _.merge(
+        {},
+        ...(mockMessageConverters ?? []).map(({ fromSchemaName, panelSettings }) =>
+          _.mapValues(panelSettings, (settings) => ({ [fromSchemaName]: settings })),
+        ),
       ),
-    ),
-
-    uninstallExtension: async (namespace: ExtensionNamespace, id: string) => {
-      const namespacedLoader = loaders.find((loader) => loader.namespace === namespace);
-      if (namespacedLoader == undefined) {
-        throw new Error("No extension loader found for namespace " + namespace);
-      }
-      await namespacedLoader.uninstallExtension(id);
-      await get().refreshExtensions();
-    },
-  }));
+    };
+  });
 }
 
 export default function ExtensionCatalogProvider({
@@ -233,12 +275,12 @@ export default function ExtensionCatalogProvider({
   const [store] = useState(createExtensionRegistryStore(loaders, mockMessageConverters));
 
   // Request an initial refresh on first mount
-  const refreshExtensions = store.getState().refreshExtensions;
+  const refreshAllExtensions = store.getState().refreshAllExtensions;
   useEffect(() => {
-    refreshExtensions().catch((err: unknown) => {
+    refreshAllExtensions().catch((err: unknown) => {
       log.error(err);
     });
-  }, [refreshExtensions]);
+  }, [refreshAllExtensions]);
 
   return (
     <ExtensionCatalogContext.Provider value={store}>{children}</ExtensionCatalogContext.Provider>
diff --git a/packages/suite-base/src/providers/helpers/buildContributionPoints.test.ts b/packages/suite-base/src/providers/helpers/buildContributionPoints.test.ts
new file mode 100644
index 0000000000..67b861e4a3
--- /dev/null
+++ b/packages/suite-base/src/providers/helpers/buildContributionPoints.test.ts
@@ -0,0 +1,193 @@
+// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
+// SPDX-License-Identifier: MPL-2.0
+
+import { TopicAliasFunction, ExtensionPanelRegistration, PanelSettings } from "@lichtblick/suite";
+import { MessageConverter } from "@lichtblick/suite-base/context/ExtensionCatalogContext";
+import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder";
+import ExtensionBuilder from "@lichtblick/suite-base/testing/builders/ExtensionBuilder";
+
+import { buildContributionPoints } from "./buildContributionPoints";
+
+describe("buildContributionPoints", () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it("should initialize contribution objects", () => {
+    const consoleErrorMock = jest.spyOn(console, "error").mockImplementation(() => {});
+    const extensionInfo = ExtensionBuilder.extension();
+
+    const result = buildContributionPoints(extensionInfo, "");
+
+    expect(result).toHaveProperty("panels", {});
+    expect(result).toHaveProperty("messageConverters", []);
+    expect(result).toHaveProperty("topicAliasFunctions", []);
+    expect(result).toHaveProperty("panelSettings", {});
+    consoleErrorMock.mockRestore();
+  });
+
+  it("should register a panel", () => {
+    const extensionInfo = ExtensionBuilder.extension();
+    const panelName = BasicBuilder.string();
+    const panelId = `${extensionInfo.qualifiedName}.${panelName}`;
+    const registration: ExtensionPanelRegistration = {
+      name: panelName,
+      initPanel: jest.fn(),
+    };
+
+    (globalThis as any).panel = registration;
+    const extensionSource = `
+      module.exports = {
+        activate: (ctx) => {
+          ctx.registerPanel(globalThis.panel);
+        }
+      };
+    `;
+
+    const result = buildContributionPoints(extensionInfo, extensionSource);
+
+    expect(result.panels[panelId]).toBeDefined();
+    expect(result.panels[panelId]).toEqual(
+      expect.objectContaining({
+        extensionId: extensionInfo.id,
+        extensionName: extensionInfo.qualifiedName,
+        extensionNamespace: extensionInfo.namespace,
+        registration: expect.objectContaining({
+          name: panelName,
+          initPanel: expect.any(Function),
+        } as ExtensionPanelRegistration),
+      }),
+    );
+    delete (globalThis as any).panel;
+  });
+
+  it("should warn when trying to register a duplicate panel", () => {
+    const logWarnMock = jest.spyOn(console, "warn").mockImplementation(() => {});
+    const extensionInfo = ExtensionBuilder.extension();
+    const panelName = BasicBuilder.string();
+    const panelId = `${extensionInfo.qualifiedName}.${panelName}`;
+    const registration: ExtensionPanelRegistration = {
+      name: panelName,
+      initPanel: jest.fn(),
+    };
+
+    (globalThis as any).panel = registration;
+    const extensionSource = `
+      module.exports = {
+        activate: (ctx) => {
+          ctx.registerPanel(globalThis.panel);
+          ctx.registerPanel(globalThis.panel);
+        }
+      };
+    `;
+
+    const result = buildContributionPoints(extensionInfo, extensionSource);
+
+    expect(result.panels[panelId]).toBeDefined();
+    expect(logWarnMock).toHaveBeenCalledWith(
+      expect.stringContaining(`Panel ${panelId} is already registered`),
+    );
+    delete (globalThis as any).panel;
+    logWarnMock.mockRestore();
+  });
+
+  it("should register a message converter", () => {
+    const extensionInfo = ExtensionBuilder.extension();
+    const messageConverter: MessageConverter = {
+      fromSchemaName: BasicBuilder.string(),
+      toSchemaName: BasicBuilder.string(),
+      panelSettings: {},
+      extensionId: extensionInfo.id,
+      converter: jest.fn(),
+    };
+
+    (globalThis as any).messageConverter = messageConverter;
+    const extensionSource = `
+      module.exports = {
+        activate: (ctx) => {
+          ctx.registerMessageConverter(globalThis.messageConverter);
+        }
+      };
+    `;
+
+    const result = buildContributionPoints(extensionInfo, extensionSource);
+
+    expect(result.messageConverters).toHaveLength(1);
+    expect(result.messageConverters.length).toBe(1);
+    expect(result.messageConverters[0]).toEqual({
+      ...messageConverter,
+      extensionNamespace: extensionInfo.namespace,
+      extensionId: extensionInfo.id,
+    });
+    delete (globalThis as any).messageConverter;
+  });
+
+  it("should register a message converter with panel settings", () => {
+    const extensionInfo = ExtensionBuilder.extension();
+    const panelSettingsA: PanelSettings<unknown> = {
+      defaultConfig: BasicBuilder.genericDictionary(String),
+      handler: jest.fn(),
+      settings: jest.fn(),
+    };
+    const panelSettingsB: PanelSettings<unknown> = {
+      defaultConfig: BasicBuilder.genericDictionary(String),
+      handler: jest.fn(),
+      settings: jest.fn(),
+    };
+    const messageConverter: MessageConverter = {
+      fromSchemaName: BasicBuilder.string(),
+      toSchemaName: BasicBuilder.string(),
+      panelSettings: {
+        panelSettingsA,
+        panelSettingsB,
+      },
+      converter: jest.fn(),
+    };
+
+    (globalThis as any).messageConverter = messageConverter;
+    const extensionSource = `
+      module.exports = {
+        activate: (ctx) => {
+          ctx.registerMessageConverter(globalThis.messageConverter);
+        }
+      };
+    `;
+
+    const result = buildContributionPoints(extensionInfo, extensionSource);
+
+    expect(result.panelSettings).toBeDefined();
+    expect(Object.keys(result.panelSettings)).toHaveLength(2);
+    expect(result.messageConverters).toHaveLength(1);
+    expect(result.messageConverters[0]?.extensionId).toEqual(extensionInfo.id);
+    expect(result.panelSettings.panelSettingsA).toHaveProperty(messageConverter.fromSchemaName);
+    expect(result.panelSettings.panelSettingsA![messageConverter.fromSchemaName]).toEqual(
+      panelSettingsA,
+    );
+    expect(result.panelSettings.panelSettingsB).toHaveProperty(messageConverter.fromSchemaName);
+    expect(result.panelSettings.panelSettingsB![messageConverter.fromSchemaName]).toEqual(
+      panelSettingsB,
+    );
+    delete (globalThis as any).messageConverter;
+  });
+
+  it("registers topic aliases correctly", () => {
+    const extensionInfo = ExtensionBuilder.extension();
+    const aliasFunction: TopicAliasFunction = jest.fn();
+
+    (globalThis as any).topicAliasFunction = aliasFunction;
+    const extensionSource = `
+      module.exports = {
+        activate: (ctx) => {
+          ctx.registerTopicAliases(globalThis.topicAliasFunction);
+        }
+      };
+    `;
+
+    const result = buildContributionPoints(extensionInfo, extensionSource);
+
+    expect(result.topicAliasFunctions).toHaveLength(1);
+    expect(result.topicAliasFunctions[0]!.extensionId).toBe(extensionInfo.id);
+    expect(result.topicAliasFunctions[0]!.aliasFunction).toBe(aliasFunction);
+    delete (globalThis as any).topicAliasFunction;
+  });
+});
diff --git a/packages/suite-base/src/providers/helpers/buildContributionPoints.ts b/packages/suite-base/src/providers/helpers/buildContributionPoints.ts
new file mode 100644
index 0000000000..619c642e3b
--- /dev/null
+++ b/packages/suite-base/src/providers/helpers/buildContributionPoints.ts
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
+// SPDX-License-Identifier: MPL-2.0
+
+import * as _ from "lodash-es";
+import ReactDOM from "react-dom";
+
+import Logger from "@lichtblick/log";
+import {
+  RegisterMessageConverterArgs,
+  ExtensionContext,
+  TopicAliasFunction,
+  ExtensionModule,
+  ExtensionPanelRegistration,
+} from "@lichtblick/suite";
+import { ExtensionSettings } from "@lichtblick/suite-base/components/PanelSettings/types";
+import {
+  ContributionPoints,
+  RegisteredPanel,
+  MessageConverter,
+} from "@lichtblick/suite-base/context/ExtensionCatalogContext";
+import { ExtensionInfo } from "@lichtblick/suite-base/types/Extensions";
+
+const log = Logger.getLogger(__filename);
+
+export function buildContributionPoints(
+  extension: ExtensionInfo,
+  unwrappedExtensionSource: string,
+): ContributionPoints {
+  // registered panels stored by their fully qualified id
+  // the fully qualified id is the extension name + panel name
+  const panels: Record<string, RegisteredPanel> = {};
+  const messageConverters: RegisterMessageConverterArgs<unknown>[] = [];
+  const panelSettings: ExtensionSettings = {};
+  const topicAliasFunctions: ContributionPoints["topicAliasFunctions"] = [];
+
+  log.debug(`Mounting extension ${extension.qualifiedName}`);
+
+  const module = { exports: {} };
+  const require = (name: string) => {
+    return { react: React, "react-dom": ReactDOM }[name];
+  };
+
+  const extensionMode =
+    process.env.NODE_ENV === "production"
+      ? "production"
+      : process.env.NODE_ENV === "test"
+        ? "test"
+        : "development";
+
+  const ctx: ExtensionContext = {
+    mode: extensionMode,
+
+    registerPanel: (registration: ExtensionPanelRegistration) => {
+      log.debug(`Extension ${extension.qualifiedName} registering panel: ${registration.name}`);
+
+      const panelId = `${extension.qualifiedName}.${registration.name}`;
+      if (panels[panelId]) {
+        log.warn(`Panel ${panelId} is already registered`);
+        return;
+      }
+
+      panels[panelId] = {
+        extensionId: extension.id,
+        extensionName: extension.qualifiedName,
+        extensionNamespace: extension.namespace,
+        registration,
+      };
+    },
+
+    registerMessageConverter: <Src>(messageConverter: RegisterMessageConverterArgs<Src>) => {
+      log.debug(
+        `Extension ${extension.qualifiedName} registering message converter from: ${messageConverter.fromSchemaName} to: ${messageConverter.toSchemaName}`,
+      );
+
+      messageConverters.push({
+        ...messageConverter,
+        extensionNamespace: extension.namespace,
+        extensionId: extension.id,
+      } as MessageConverter);
+
+      const converterSettings = _.mapValues(messageConverter.panelSettings, (settings) => ({
+        [messageConverter.fromSchemaName]: settings,
+      }));
+
+      _.merge(panelSettings, converterSettings);
+    },
+
+    registerTopicAliases: (aliasFunction: TopicAliasFunction) => {
+      topicAliasFunctions.push({ aliasFunction, extensionId: extension.id });
+    },
+  };
+
+  try {
+    // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval
+    const fn = new Function("module", "require", unwrappedExtensionSource);
+
+    // load the extension module exports
+    fn(module, require, {});
+    const wrappedExtensionModule = module.exports as ExtensionModule;
+
+    wrappedExtensionModule.activate(ctx);
+  } catch (err: unknown) {
+    log.error(err);
+  }
+
+  return {
+    panels,
+    messageConverters,
+    topicAliasFunctions,
+    panelSettings,
+  };
+}
diff --git a/packages/suite-base/src/services/ExtensionLoader.ts b/packages/suite-base/src/services/ExtensionLoader.ts
index baaf4f17c5..a20882159b 100644
--- a/packages/suite-base/src/services/ExtensionLoader.ts
+++ b/packages/suite-base/src/services/ExtensionLoader.ts
@@ -14,6 +14,9 @@ import { ExtensionInfo, ExtensionNamespace } from "@lichtblick/suite-base/types/
 export interface ExtensionLoader {
   readonly namespace: ExtensionNamespace;
 
+  // get extension by id
+  getExtension(id: string): Promise<ExtensionInfo | undefined>;
+
   // get a list of installed extensions
   getExtensions(): Promise<ExtensionInfo[]>;
 
diff --git a/packages/suite-base/src/services/IdbExtensionLoader.test.ts b/packages/suite-base/src/services/IdbExtensionLoader.test.ts
index e1242c7088..e1366d3e12 100644
--- a/packages/suite-base/src/services/IdbExtensionLoader.test.ts
+++ b/packages/suite-base/src/services/IdbExtensionLoader.test.ts
@@ -7,14 +7,24 @@
 
 import fs from "fs";
 import { openDB } from "idb/with-async-ittr";
+import JSZip from "jszip";
 
-import { IdbExtensionLoader } from "./IdbExtensionLoader";
+import { StoredExtension } from "@lichtblick/suite-base/services/IExtensionStorage";
+import {
+  EXTENSION_STORE_NAME,
+  METADATA_STORE_NAME,
+} from "@lichtblick/suite-base/services/IdbExtensionStorage";
+import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder";
+import { Capitalization } from "@lichtblick/suite-base/testing/builders/types";
+import { ExtensionInfo } from "@lichtblick/suite-base/types/Extensions";
+
+import { IdbExtensionLoader, validatePackageInfo, ALLOWED_FILES } from "./IdbExtensionLoader";
 
 jest.mock("idb/with-async-ittr", () => ({
   openDB: jest.fn(),
 }));
 
-const pkgInfo = {
+const packageJson: any = {
   description: "",
   devDependencies: {
     "@foxglove/fox": "file:../fox",
@@ -37,95 +47,292 @@ const pkgInfo = {
   version: "0.0.1",
 };
 
-describe("IdbExtensionLoader", () => {
-  const mockDBPut = jest.fn();
+const EXT_FILE_TURTLESIM = `${__dirname}/../test/fixtures/lichtblick.suite-extension-turtlesim-0.0.1.foxe`;
+const EXT_FILE_PREFIXED = `${__dirname}/../test/fixtures/prefixed-name-extension.foxe`;
+
+jest.mock("@lichtblick/log", () => ({
+  getLogger: jest.fn(() => ({
+    debug: jest.fn(),
+  })),
+}));
 
-  const mockDBGetAll = jest.fn();
+describe("IdbExtensionLoader", () => {
+  const mockGet = jest.fn();
+  const mockGetAll = jest.fn();
+  const mockPut = jest.fn();
+  const mockDelete = jest.fn();
 
   beforeEach(() => {
     (openDB as jest.Mock).mockReturnValue({
-      transaction: jest.fn().mockReturnValue({ db: { put: mockDBPut } }),
-      getAll: mockDBGetAll,
+      transaction: jest.fn().mockReturnValue({ db: { put: mockPut, delete: mockDelete } }),
+      getAll: mockGetAll,
+      get: mockGet,
+      delete: mockDelete,
     });
   });
 
-  describe("loading extensions", () => {
+  describe("installExtension", () => {
     afterEach(() => {
       jest.clearAllMocks();
     });
 
-    it("Installs local extensions", async () => {
-      const foxe = fs.readFileSync(
-        `${__dirname}/../test/fixtures/lichtblick.suite-extension-turtlesim-0.0.1.foxe`,
-      );
-
-      const expectedInfo = {
-        ...pkgInfo,
+    it("should install local extensions", async () => {
+      const foxe = fs.readFileSync(EXT_FILE_TURTLESIM);
+      const info: ExtensionInfo = {
+        ...packageJson,
         namespace: "local",
         qualifiedName: "turtlesim",
-      };
-
-      mockDBGetAll.mockReturnValue([expectedInfo]);
-
+      } as ExtensionInfo;
       const loader = new IdbExtensionLoader("local");
-      await loader.installExtension(foxe);
-
-      expect(mockDBPut).toHaveBeenCalledWith("metadata", expectedInfo);
 
-      expect(mockDBPut).toHaveBeenCalledWith("extensions", { content: foxe, info: expectedInfo });
+      await loader.installExtension(foxe as unknown as Uint8Array);
 
-      expect((await loader.getExtensions())[0]).toBe(expectedInfo);
+      expect(mockPut).toHaveBeenCalledWith(METADATA_STORE_NAME, info);
+      expect(mockPut).toHaveBeenCalledWith(EXTENSION_STORE_NAME, {
+        content: foxe,
+        info,
+      });
     });
 
-    it("Installs private extensions", async () => {
-      const foxe = fs.readFileSync(
-        `${__dirname}/../test/fixtures/lichtblick.suite-extension-turtlesim-0.0.1.foxe`,
-      );
-
-      const expectedInfo = {
-        ...pkgInfo,
+    it("should install private extensions", async () => {
+      const foxe = fs.readFileSync(EXT_FILE_TURTLESIM);
+      const info: ExtensionInfo = {
+        ...packageJson,
         namespace: "org",
         qualifiedName: "org:Foxglove Inc:studio-extension-turtlesim",
-      };
-
-      mockDBGetAll.mockReturnValue([expectedInfo]);
-
+      } as ExtensionInfo;
+      mockGetAll.mockReturnValue([info]);
       const loader = new IdbExtensionLoader("org");
-      await loader.installExtension(foxe);
 
-      expect(mockDBPut).toHaveBeenCalledWith("metadata", expectedInfo);
+      await loader.installExtension(foxe as unknown as Uint8Array);
 
-      expect(mockDBPut).toHaveBeenCalledWith("extensions", {
+      expect(mockPut).toHaveBeenCalledWith(METADATA_STORE_NAME, info);
+      expect(mockPut).toHaveBeenCalledWith(EXTENSION_STORE_NAME, {
         content: foxe,
-        info: expectedInfo,
+        info,
       });
-
-      expect((await loader.getExtensions())[0]).toBe(expectedInfo);
+      expect((await loader.getExtensions())[0]).toBe(info);
     });
 
-    it("Parses package prefixes", async () => {
-      const foxe = fs.readFileSync(`${__dirname}/../test/fixtures/prefixed-name-extension.foxe`);
-      const expectedInfo = {
+    it("should parse package prefixes", async () => {
+      const foxe = fs.readFileSync(EXT_FILE_PREFIXED);
+      const info: ExtensionInfo = {
         id: "Prefix.package-name",
         name: "package-name",
         namespace: "org",
         publisher: "Prefix",
         qualifiedName: "org:Prefix:package-name",
-      };
-
-      mockDBGetAll.mockReturnValue([expectedInfo]);
+      } as ExtensionInfo;
 
+      mockGetAll.mockReturnValue([info]);
       const loader = new IdbExtensionLoader("org");
-      await loader.installExtension(foxe);
 
-      expect(mockDBPut).toHaveBeenCalledWith("metadata", expectedInfo);
+      await loader.installExtension(foxe as unknown as Uint8Array);
 
-      expect(mockDBPut).toHaveBeenCalledWith("extensions", {
+      expect(mockPut).toHaveBeenCalledWith(METADATA_STORE_NAME, info);
+      expect(mockPut).toHaveBeenCalledWith(EXTENSION_STORE_NAME, {
         content: foxe,
+        info,
+      });
+      expect((await loader.getExtensions())[0]).toBe(info);
+    });
+  });
+
+  describe("loadExtension", () => {
+    it("should successfully load an extension with valid files", async () => {
+      const loader = new IdbExtensionLoader("local");
+      const rawContent = "console.log('valid extension');";
+      const jsZip = new JSZip();
+      jsZip.file(ALLOWED_FILES.EXTENSION, rawContent);
+      jest.spyOn(JSZip.prototype, "loadAsync").mockResolvedValue(jsZip);
+      const extension: StoredExtension = {
+        info: {
+          id: BasicBuilder.string(),
+        } as ExtensionInfo,
+        content: await jsZip.generateAsync({ type: "uint8array" }),
+      };
+      mockGet.mockReturnValueOnce(extension);
+
+      const result = await loader.loadExtension(extension.info.id);
+
+      expect(mockGet).toHaveBeenCalledWith(EXTENSION_STORE_NAME, extension.info.id);
+      expect(result).toContain(rawContent);
+    });
+
+    it("should throw an error if the extension is not found", async () => {
+      const loader = new IdbExtensionLoader("local");
+      mockGet.mockResolvedValue(undefined);
+
+      await expect(loader.loadExtension(BasicBuilder.string())).rejects.toThrow(
+        "Extension not found",
+      );
+    });
+
+    it("should throw an error if extension content is missing", async () => {
+      const loader = new IdbExtensionLoader("local");
+      const extension: StoredExtension = {
+        info: {
+          id: BasicBuilder.string(),
+        } as ExtensionInfo,
+        content: undefined as any,
+      };
+      mockGet.mockResolvedValue(undefined);
+
+      await expect(loader.loadExtension(extension.info.id)).rejects.toThrow("Extension not found");
+    });
+
+    it("should throw an error if the main extension script is missing", async () => {
+      const loader = new IdbExtensionLoader("local");
+      const rawContent = "console.log('valid extension');";
+      const jsZip = new JSZip();
+      jsZip.file(BasicBuilder.string(), rawContent);
+      jest.spyOn(JSZip.prototype, "loadAsync").mockResolvedValue(jsZip);
+      const extension: StoredExtension = {
+        info: {
+          id: BasicBuilder.string(),
+        } as ExtensionInfo,
+        content: await jsZip.generateAsync({ type: "uint8array" }),
+      };
+      mockGet.mockReturnValueOnce(extension);
+
+      await expect(loader.loadExtension(extension.info.id)).rejects.toThrow(
+        `Extension is corrupted: missing ${ALLOWED_FILES.EXTENSION}`,
+      );
+    });
+  });
+
+  describe("getExtension", () => {
+    it("should return the proper extension when call get extension", async () => {
+      const foxe = fs.readFileSync(EXT_FILE_TURTLESIM);
+      const expectedInfo: ExtensionInfo = {
+        ...packageJson,
+        namespace: "local",
+        qualifiedName: "turtlesim",
+      } as ExtensionInfo;
+      mockGet.mockReturnValue({
         info: expectedInfo,
+      } as StoredExtension);
+      const loader = new IdbExtensionLoader("local");
+
+      await loader.installExtension(foxe as unknown as Uint8Array);
+      const result = await loader.getExtension(expectedInfo.id);
+
+      expect(mockGet).toHaveBeenCalledWith(EXTENSION_STORE_NAME, expectedInfo.id);
+      expect(result).toBe(expectedInfo);
+    });
+  });
+
+  describe("validatePackageInfo", () => {
+    const setup = (infoOverride: Partial<ExtensionInfo> = {}) => {
+      const info: Pick<ExtensionInfo, "name" | "publisher"> = {
+        name: BasicBuilder.string(),
+        publisher: BasicBuilder.string(),
+        ...infoOverride,
+      };
+
+      return {
+        info,
+      };
+    };
+
+    it("should convert the name to lowercase", () => {
+      const packageName = BasicBuilder.string();
+      const input: Partial<ExtensionInfo> = {
+        name: `@${BasicBuilder.string()}/${packageName}`,
+        publisher: BasicBuilder.string(),
+      };
+
+      const result = validatePackageInfo(input);
+
+      expect(result.name).toBe(packageName.toLowerCase());
+    });
+
+    it("should prioritize an explicitly provided publisher over the extracted one", () => {
+      const packageName = BasicBuilder.string({ capitalization: Capitalization.LOWERCASE });
+      const publisher = BasicBuilder.string();
+      const input: Partial<ExtensionInfo> = {
+        name: `@extracted/${packageName}`,
+        publisher,
+      };
+
+      const result = validatePackageInfo(input);
+
+      expect(result.publisher).toBe(publisher);
+      expect(result.name).toBe(packageName);
+    });
+
+    it("should return a valid ExtensionInfo object when input data is correct", () => {
+      const packageName = BasicBuilder.string({ capitalization: Capitalization.LOWERCASE });
+      const publisher = BasicBuilder.string();
+      const { info } = setup({
+        name: `@${BasicBuilder.string()}/${packageName}`,
+        publisher,
       });
 
-      expect((await loader.getExtensions())[0]).toBe(expectedInfo);
+      const result = validatePackageInfo(info);
+
+      expect(result).toEqual({
+        publisher,
+        name: packageName,
+      });
+    });
+
+    it.each([
+      {
+        name: undefined,
+        publisher: BasicBuilder.string(),
+      },
+      {
+        name: "",
+        publisher: BasicBuilder.string(),
+      },
+    ])("should throw an error if the name is missing", (extensionInfo: Partial<ExtensionInfo>) => {
+      const { info } = setup(extensionInfo);
+
+      expect(() => validatePackageInfo(info)).toThrow("Invalid extension: missing name");
+    });
+
+    it("should throw an error if the publisher is missing", () => {
+      const nonStandardName = BasicBuilder.string();
+      const input: Partial<ExtensionInfo> = { name: nonStandardName };
+
+      expect(() => validatePackageInfo(input)).toThrow("Invalid extension: missing publisher");
+    });
+
+    it("should correctly extract the publisher from the name if not explicitly provided", () => {
+      const packageName = BasicBuilder.string({ capitalization: Capitalization.LOWERCASE });
+      const publisher = BasicBuilder.string({ capitalization: Capitalization.LOWERCASE });
+      const { info } = setup({
+        name: `@${publisher}/${packageName}`,
+        publisher,
+      });
+
+      const result = validatePackageInfo(info);
+
+      expect(result.publisher).toBe(publisher);
+      expect(result.name).toBe(packageName);
+    });
+
+    it("should throw an error if the extracted publisher is an empty string", () => {
+      const { info } = setup({
+        name: "@/package",
+        publisher: undefined,
+      });
+
+      expect(() => validatePackageInfo(info)).toThrow("Invalid extension: missing publisher");
+    });
+  });
+
+  describe("uninstallExtension", () => {
+    it("should successfully uninstall an extension", async () => {
+      const extensionId = BasicBuilder.string();
+      const loader = new IdbExtensionLoader("local");
+
+      await loader.uninstallExtension(extensionId);
+
+      expect(mockDelete).toHaveBeenCalledTimes(2);
+      expect(mockDelete).toHaveBeenNthCalledWith(1, METADATA_STORE_NAME, extensionId);
+      expect(mockDelete).toHaveBeenNthCalledWith(2, EXTENSION_STORE_NAME, extensionId);
     });
   });
 });
diff --git a/packages/suite-base/src/services/IdbExtensionLoader.ts b/packages/suite-base/src/services/IdbExtensionLoader.ts
index 9ff316a805..a05cf4bae0 100644
--- a/packages/suite-base/src/services/IdbExtensionLoader.ts
+++ b/packages/suite-base/src/services/IdbExtensionLoader.ts
@@ -9,19 +9,28 @@ import JSZip from "jszip";
 
 import Log from "@lichtblick/log";
 import { ExtensionLoader } from "@lichtblick/suite-base/services/ExtensionLoader";
-import { IExtensionStorage } from "@lichtblick/suite-base/services/IExtensionStorage";
+import {
+  IExtensionStorage,
+  StoredExtension,
+} from "@lichtblick/suite-base/services/IExtensionStorage";
 import { ExtensionInfo, ExtensionNamespace } from "@lichtblick/suite-base/types/Extensions";
 
 import { IdbExtensionStorage } from "./IdbExtensionStorage";
 
 const log = Log.getLogger(__filename);
 
+export enum ALLOWED_FILES {
+  EXTENSION = "dist/extension.js",
+  PACKAGE = "package.json",
+}
+
 function parsePackageName(name: string): { publisher?: string; name: string } {
-  const res = /^@([^/]+)\/(.+)/.exec(name);
-  if (res == undefined) {
+  const match = new RegExp(/^@([^/]+)\/(.+)/).exec(name);
+  if (!match) {
     return { name };
   }
-  return { publisher: res[1], name: res[2]! };
+
+  return { publisher: match[1], name: match[2]! };
 }
 
 function qualifiedName(
@@ -40,13 +49,13 @@ function qualifiedName(
   }
 }
 
-function validatePackageInfo(info: Partial<ExtensionInfo>): ExtensionInfo {
-  if (info.name == undefined || info.name.length === 0) {
+export function validatePackageInfo(info: Partial<ExtensionInfo>): ExtensionInfo {
+  if (!info.name || info.name.length === 0) {
     throw new Error("Invalid extension: missing name");
   }
   const { publisher: parsedPublisher, name } = parsePackageName(info.name);
   const publisher = info.publisher ?? parsedPublisher;
-  if (publisher == undefined || publisher.length === 0) {
+  if (!publisher || publisher.length === 0) {
     throw new Error("Invalid extension: missing publisher");
   }
 
@@ -62,6 +71,13 @@ export class IdbExtensionLoader implements ExtensionLoader {
     this.#storage = new IdbExtensionStorage(namespace);
   }
 
+  public async getExtension(id: string): Promise<ExtensionInfo | undefined> {
+    log.debug("Get extension", id);
+
+    const storedExtension = await this.#storage.get(id);
+    return storedExtension?.info;
+  }
+
   public async getExtensions(): Promise<ExtensionInfo[]> {
     log.debug("Listing extensions");
 
@@ -71,21 +87,18 @@ export class IdbExtensionLoader implements ExtensionLoader {
   public async loadExtension(id: string): Promise<string> {
     log.debug("Loading extension", id);
 
-    const extension = await this.#storage.get(id);
-    const zip = new JSZip();
-
-    if (extension?.content == undefined) {
-      throw new Error("Extension is corrupted");
+    const extension: StoredExtension | undefined = await this.#storage.get(id);
+    if (!extension) {
+      throw new Error("Extension not found");
     }
 
-    const content = await zip.loadAsync(extension.content);
-    const srcText = await content.file("dist/extension.js")?.async("string");
-
-    if (srcText == undefined) {
-      throw new Error("Extension is corrupted");
+    const content = await new JSZip().loadAsync(extension.content);
+    const rawContent = await content.file(ALLOWED_FILES.EXTENSION)?.async("string");
+    if (!rawContent) {
+      throw new Error(`Extension is corrupted: missing ${ALLOWED_FILES.EXTENSION}`);
     }
 
-    return srcText;
+    return rawContent;
   }
 
   public async installExtension(foxeFileData: Uint8Array): Promise<ExtensionInfo> {
@@ -94,9 +107,9 @@ export class IdbExtensionLoader implements ExtensionLoader {
     const zip = new JSZip();
     const content = await zip.loadAsync(foxeFileData);
 
-    const pkgInfoText = await content.file("package.json")?.async("string");
-    if (pkgInfoText == undefined) {
-      throw new Error("Invalid extension: missing package.json");
+    const pkgInfoText = await content.file(ALLOWED_FILES.PACKAGE)?.async("string");
+    if (!pkgInfoText) {
+      throw new Error(`Invalid extension: missing ${ALLOWED_FILES.PACKAGE}`);
     }
 
     const rawInfo = validatePackageInfo(JSON.parse(pkgInfoText) as Partial<ExtensionInfo>);
diff --git a/packages/suite-base/src/services/IdbExtensionStorage.ts b/packages/suite-base/src/services/IdbExtensionStorage.ts
index b56a7cc6c5..af2b2b62ab 100644
--- a/packages/suite-base/src/services/IdbExtensionStorage.ts
+++ b/packages/suite-base/src/services/IdbExtensionStorage.ts
@@ -17,8 +17,8 @@ import { ExtensionInfo } from "@lichtblick/suite-base/types/Extensions";
 const log = Log.getLogger(__filename);
 
 const DATABASE_BASE_NAME = "foxglove-extensions";
-const METADATA_STORE_NAME = "metadata";
-const EXTENSION_STORE_NAME = "extensions";
+export const METADATA_STORE_NAME = "metadata";
+export const EXTENSION_STORE_NAME = "extensions";
 
 interface ExtensionsDB extends IDB.DBSchema {
   metadata: {
diff --git a/packages/suite-base/src/testing/builders/ExtensionBuilder.ts b/packages/suite-base/src/testing/builders/ExtensionBuilder.ts
new file mode 100644
index 0000000000..611d600cfb
--- /dev/null
+++ b/packages/suite-base/src/testing/builders/ExtensionBuilder.ts
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
+// SPDX-License-Identifier: MPL-2.0
+
+import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder";
+import { defaults } from "@lichtblick/suite-base/testing/builders/utilities";
+import { ExtensionInfo, ExtensionNamespace } from "@lichtblick/suite-base/types/Extensions";
+
+export default class ExtensionBuilder {
+  public static extension(props: Partial<ExtensionInfo> = {}): ExtensionInfo {
+    return defaults<ExtensionInfo>(props, {
+      description: BasicBuilder.string(),
+      displayName: BasicBuilder.string(),
+      homepage: BasicBuilder.string(),
+      id: BasicBuilder.string(),
+      keywords: BasicBuilder.strings(),
+      license: BasicBuilder.string(),
+      name: BasicBuilder.string(),
+      namespace: BasicBuilder.sample(["local", "org"] as ExtensionNamespace[]),
+      publisher: BasicBuilder.string(),
+      qualifiedName: BasicBuilder.string(),
+      version: BasicBuilder.string(),
+    });
+  }
+}
diff --git a/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.test.ts b/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.test.ts
new file mode 100644
index 0000000000..0dae77ee8c
--- /dev/null
+++ b/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.test.ts
@@ -0,0 +1,177 @@
+// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
+// SPDX-License-Identifier: MPL-2.0
+import randomString from "randomstring";
+
+import { ExtensionInfo } from "@lichtblick/suite-base";
+
+import { DesktopExtensionLoader } from "./DesktopExtensionLoader";
+import { Desktop, DesktopExtension } from "../../common/types";
+
+jest.mock("@lichtblick/log", () => ({
+  getLogger: jest.fn(() => ({
+    debug: jest.fn(),
+  })),
+}));
+
+const genericString = (): string =>
+  randomString.generate({ length: 6, charset: "alphanumeric", capitalization: "lowercase" });
+
+describe("DesktopExtensionLoader", () => {
+  let mockBridge: jest.Mocked<Desktop>;
+  let loader: DesktopExtensionLoader;
+
+  beforeEach(() => {
+    mockBridge = {
+      getExtensions: jest.fn(),
+      loadExtension: jest.fn(),
+      installExtension: jest.fn(),
+      uninstallExtension: jest.fn(),
+    } as unknown as jest.Mocked<Desktop>;
+
+    loader = new DesktopExtensionLoader(mockBridge);
+  });
+
+  describe("getExtension", () => {
+    it("should return an extension if it exists", async () => {
+      const desktopExtensionLoader = new DesktopExtensionLoader(mockBridge);
+      const displayName = genericString();
+      const extension: DesktopExtension = {
+        id: genericString(),
+        packageJson: { displayName },
+      } as DesktopExtension;
+
+      mockBridge.getExtensions.mockResolvedValueOnce([extension]);
+
+      const result = await loader.getExtension(extension.id);
+
+      expect(mockBridge.getExtensions).toHaveBeenCalledTimes(1);
+      expect(result).toEqual({
+        id: extension.id,
+        name: displayName,
+        namespace: desktopExtensionLoader.namespace,
+        qualifiedName: displayName,
+        displayName,
+      } as ExtensionInfo);
+    });
+
+    it("should return undefined if the extension does not exist", async () => {
+      const nonExistentId = genericString();
+      mockBridge.getExtensions.mockResolvedValueOnce([]);
+
+      const result = await loader.getExtension(nonExistentId);
+
+      expect(mockBridge.getExtensions).toHaveBeenCalledTimes(1);
+      expect(result).toBeUndefined();
+    });
+  });
+
+  describe("getExtensions", () => {
+    it("should return a list of extensions", async () => {
+      const desktopExtensionLoader = new DesktopExtensionLoader(mockBridge);
+      const extensions: DesktopExtension[] = [
+        {
+          id: genericString(),
+          packageJson: { displayName: genericString() } as ExtensionInfo,
+        } as DesktopExtension,
+        {
+          id: genericString(),
+          packageJson: { displayName: genericString() } as ExtensionInfo,
+        } as DesktopExtension,
+      ];
+      mockBridge.getExtensions.mockResolvedValue(extensions);
+
+      const result = await loader.getExtensions();
+
+      expect(mockBridge.getExtensions).toHaveBeenCalledTimes(1);
+      expect(result).toEqual([
+        {
+          ...(extensions[0]?.packageJson as ExtensionInfo),
+          id: extensions[0]?.id,
+          name: (extensions[0]?.packageJson as ExtensionInfo).displayName,
+          namespace: desktopExtensionLoader.namespace,
+          qualifiedName: (extensions[0]?.packageJson as ExtensionInfo).displayName,
+        },
+        {
+          ...(extensions[1]?.packageJson as ExtensionInfo),
+          id: extensions[1]?.id,
+          name: (extensions[1]?.packageJson as ExtensionInfo).displayName,
+          namespace: desktopExtensionLoader.namespace,
+          qualifiedName: (extensions[1]?.packageJson as ExtensionInfo).displayName,
+        },
+      ]);
+    });
+
+    it("should return an empty array if no extensions are available", async () => {
+      mockBridge.getExtensions.mockResolvedValue([]);
+
+      const result = await loader.getExtensions();
+
+      expect(mockBridge.getExtensions).toHaveBeenCalledTimes(1);
+      expect(result).toEqual([]);
+    });
+  });
+
+  describe("loadExtension", () => {
+    it("should return the extension content if loaded successfully", async () => {
+      const rawContent = "console.log('loaded');";
+      const extensionId = genericString();
+      mockBridge.loadExtension.mockResolvedValue(rawContent);
+
+      const result = await loader.loadExtension(extensionId);
+
+      expect(mockBridge.loadExtension).toHaveBeenCalledWith(extensionId);
+      expect(result).toBe(rawContent);
+    });
+
+    it("should return an empty string if the bridge is not available", async () => {
+      const nonExistentId = genericString();
+      loader = new DesktopExtensionLoader(undefined as unknown as Desktop);
+
+      const result = await loader.loadExtension(nonExistentId);
+
+      expect(result).toBe("");
+    });
+  });
+
+  describe("installExtension", () => {
+    it("should install an extension successfully", async () => {
+      const desktopExtensionLoader = new DesktopExtensionLoader(mockBridge);
+      const displayName = genericString();
+      const foxeFileData = new Uint8Array([1, 2, 3]);
+      const extension: DesktopExtension = {
+        id: genericString(),
+        packageJson: { displayName } as ExtensionInfo,
+      } as DesktopExtension;
+
+      mockBridge.installExtension.mockResolvedValue(extension);
+
+      const result = await loader.installExtension(foxeFileData);
+
+      expect(mockBridge.installExtension).toHaveBeenCalledWith(foxeFileData);
+      expect(result).toEqual({
+        ...(extension.packageJson as ExtensionInfo),
+        id: extension.id,
+        name: displayName,
+        namespace: desktopExtensionLoader.namespace,
+        qualifiedName: displayName,
+      });
+    });
+
+    it("should throw an error if bridge is undefined", async () => {
+      loader = new DesktopExtensionLoader(undefined as unknown as Desktop);
+
+      await expect(loader.installExtension(new Uint8Array([1, 2, 3]))).rejects.toThrow(
+        "Cannot install extension without a desktopBridge",
+      );
+    });
+  });
+
+  describe("uninstallExtension", () => {
+    it("should successfully uninstall an extension", async () => {
+      const extensionId = genericString();
+      await loader.uninstallExtension(extensionId);
+
+      expect(mockBridge.uninstallExtension).toHaveBeenCalledWith(extensionId);
+    });
+  });
+});
diff --git a/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.ts b/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.ts
index 4b7c73a96a..4baef6c729 100644
--- a/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.ts
+++ b/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.ts
@@ -8,7 +8,7 @@
 import Logger from "@lichtblick/log";
 import { ExtensionInfo, ExtensionLoader, ExtensionNamespace } from "@lichtblick/suite-base";
 
-import { Desktop } from "../../common/types";
+import { Desktop, DesktopExtension } from "../../common/types";
 
 const log = Logger.getLogger(__filename);
 
@@ -20,25 +20,24 @@ export class DesktopExtensionLoader implements ExtensionLoader {
     this.#bridge = bridge;
   }
 
+  public async getExtension(id: string): Promise<ExtensionInfo | undefined> {
+    const storedExtension = (await this.getExtensions()).find((extension) => extension.id === id);
+    return storedExtension;
+  }
+
   public async getExtensions(): Promise<ExtensionInfo[]> {
     const extensionList = (await this.#bridge?.getExtensions()) ?? [];
     log.debug(`Loaded ${extensionList.length} extension(s)`);
 
-    const extensions = extensionList.map((item): ExtensionInfo => {
-      const pkgInfo = item.packageJson as ExtensionInfo;
+    const extensions = extensionList.map((extension: DesktopExtension): ExtensionInfo => {
+      const pkgInfo = extension.packageJson as ExtensionInfo;
       return {
-        id: item.id,
+        ...pkgInfo,
+        id: extension.id,
         name: pkgInfo.displayName,
         namespace: this.namespace,
         // Qualified name is display name for backwards compatibility with existing layouts.
         qualifiedName: pkgInfo.displayName,
-        displayName: pkgInfo.displayName,
-        description: pkgInfo.description,
-        publisher: pkgInfo.publisher,
-        homepage: pkgInfo.homepage,
-        license: pkgInfo.license,
-        version: pkgInfo.version,
-        keywords: pkgInfo.keywords,
       };
     });
 
@@ -53,23 +52,17 @@ export class DesktopExtensionLoader implements ExtensionLoader {
     if (this.#bridge == undefined) {
       throw new Error(`Cannot install extension without a desktopBridge`);
     }
-    const detail = await this.#bridge.installExtension(foxeFileData);
 
-    const pkgInfo = detail.packageJson as ExtensionInfo;
+    const extension: DesktopExtension = await this.#bridge.installExtension(foxeFileData);
+    const pkgInfo = extension.packageJson as ExtensionInfo;
 
     return {
-      id: detail.id,
+      ...pkgInfo,
+      id: extension.id,
       name: pkgInfo.displayName,
       namespace: this.namespace,
       // Qualified name is display name for backwards compatibility with existing layouts.
       qualifiedName: pkgInfo.displayName,
-      displayName: pkgInfo.displayName,
-      description: pkgInfo.description,
-      publisher: pkgInfo.publisher,
-      homepage: pkgInfo.homepage,
-      license: pkgInfo.license,
-      version: pkgInfo.version,
-      keywords: pkgInfo.keywords,
     };
   }
 


Reviews:
user: login: aneuwald-ctw
body: Left some questions
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T20:44:10Z
user: login: luluiz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T07:56:48Z
user: login: luluiz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T07:56:57Z
user: login: luluiz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T09:18:39Z
user: login: aneuwald-ctw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T09:19:38Z
user: login: luluiz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T09:33:34Z
user: login: aneuwald-ctw
body: thanks for the effort 💪 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T10:39:11Z
user: login: aneuwald-ctw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T10:39:54Z
user: login: aneuwald-ctw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T10:40:01Z

Review comments:
diff_hunk: @@ -229,69 +229,44 @@ function WorkspaceContent(props: WorkspaceProps): React.JSX.Element {
 
   const { enqueueSnackbar } = useSnackbar();
 
-  const installExtension = useExtensionCatalog((state) => state.installExtension);
+  const installExtensions = useExtensionCatalog((state) => state.installExtensions);
 
-  const openHandles = useCallback(
-    async (
-      handles: FileSystemFileHandle[] /* foxglove-depcheck-used: @types/wicg-file-system-access */,
-    ) => {
-      log.debug("open handles", handles);
-      const files = await Promise.all(handles.map(async (handle) => await handle.getFile()));
-
-      for (const file of files) {
-        if (file.name.endsWith(".foxe")) {
-          // Extension installation
-          try {
-            const arrayBuffer = await file.arrayBuffer();
-            const data = new Uint8Array(arrayBuffer);
-            const extension = await installExtension("local", data);
-            enqueueSnackbar(`Installed extension ${extension.id}`, { variant: "success" });
-          } catch (e: unknown) {
-            const err = e as Error;
-            log.error(err);
-            enqueueSnackbar(`Failed to install extension ${file.name}: ${err.message}`, {
-              variant: "error",
-            });
-          }
-        }
-      }
-
-      // Look for a source that supports the file extensions
-      const matchedSource = availableSources.find((source) => {
-        const ext = extname(files[0]!.name);
-        return source.supportedFileTypes?.includes(ext);
-      });
-      if (matchedSource) {
-        selectSource(matchedSource.id, { type: "file", handles });
+  const handleFiles = useCallback(
+    async (files: File[]) => {
+      if (files.length === 0) {
+        return;
       }
-    },
-    [availableSources, enqueueSnackbar, installExtension, selectSource],
-  );
 
-  const openFiles = useCallback(
-    async (files: File[]) => {
       const otherFiles: File[] = [];
       log.debug("open files", files);
 
+      const data: Uint8Array[] = [];
       for (const file of files) {
         if (file.name.endsWith(".foxe")) {
-          // Extension installation
-          try {
-            const arrayBuffer = await file.arrayBuffer();
-            const data = new Uint8Array(arrayBuffer);
-            const extension = await installExtension("local", data);
-            enqueueSnackbar(`Installed extension ${extension.id}`, { variant: "success" });
-          } catch (err: unknown) {
-            log.error(err);
-            enqueueSnackbar(`Failed to install extension ${file.name}: ${(err as Error).message}`, {
-              variant: "error",
-            });
-          }
+          const arrayBuffer = await file.arrayBuffer();
path: packages/suite-base/src/Workspace.tsx
user: login: aneuwald-ctw
body: is there any place handle the error if file.arrayBuffer fails? Ideally every wait should be inside a try/catch block
created_at: 2025-03-17T20:32:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -456,6 +456,7 @@ export interface PanelSettings<ExtensionSettings> {
    */
   handler: (action: SettingsTreeAction, config?: ExtensionSettings) => void;
   defaultConfig?: ExtensionSettings;
+  extensionId?: string;
path: packages/suite/src/index.ts
user: login: aneuwald-ctw
body: why the extensionId was added to PanelSettings of message converters?
created_at: 2025-03-17T20:42:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -466,6 +467,7 @@ export type RegisterMessageConverterArgs<Src> = {
    * Custom settings for the topics using the schema specified in the *toSchemaName* property
    */
   panelSettings?: Record<string, PanelSettings<unknown>>;
+  extensionId?: string;
path: packages/suite/src/index.ts
user: login: aneuwald-ctw
body: why the extensionId was added to the message converter?
created_at: 2025-03-17T20:42:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -456,6 +456,7 @@ export interface PanelSettings<ExtensionSettings> {
    */
   handler: (action: SettingsTreeAction, config?: ExtensionSettings) => void;
   defaultConfig?: ExtensionSettings;
+  extensionId?: string;
path: packages/suite/src/index.ts
user: login: luluiz
body: It was added to have as a reference to uninstall everything that was installed with the extension.
created_at: 2025-03-18T07:56:48Z
author_association: MEMBER
diff_hunk: @@ -466,6 +467,7 @@ export type RegisterMessageConverterArgs<Src> = {
    * Custom settings for the topics using the schema specified in the *toSchemaName* property
    */
   panelSettings?: Record<string, PanelSettings<unknown>>;
+  extensionId?: string;
path: packages/suite/src/index.ts
user: login: luluiz
body: It was added to have as a reference to uninstall everything that was installed with the extension.
created_at: 2025-03-18T07:56:57Z
author_association: MEMBER
diff_hunk: @@ -229,69 +229,44 @@ function WorkspaceContent(props: WorkspaceProps): React.JSX.Element {
 
   const { enqueueSnackbar } = useSnackbar();
 
-  const installExtension = useExtensionCatalog((state) => state.installExtension);
+  const installExtensions = useExtensionCatalog((state) => state.installExtensions);
 
-  const openHandles = useCallback(
-    async (
-      handles: FileSystemFileHandle[] /* foxglove-depcheck-used: @types/wicg-file-system-access */,
-    ) => {
-      log.debug("open handles", handles);
-      const files = await Promise.all(handles.map(async (handle) => await handle.getFile()));
-
-      for (const file of files) {
-        if (file.name.endsWith(".foxe")) {
-          // Extension installation
-          try {
-            const arrayBuffer = await file.arrayBuffer();
-            const data = new Uint8Array(arrayBuffer);
-            const extension = await installExtension("local", data);
-            enqueueSnackbar(`Installed extension ${extension.id}`, { variant: "success" });
-          } catch (e: unknown) {
-            const err = e as Error;
-            log.error(err);
-            enqueueSnackbar(`Failed to install extension ${file.name}: ${err.message}`, {
-              variant: "error",
-            });
-          }
-        }
-      }
-
-      // Look for a source that supports the file extensions
-      const matchedSource = availableSources.find((source) => {
-        const ext = extname(files[0]!.name);
-        return source.supportedFileTypes?.includes(ext);
-      });
-      if (matchedSource) {
-        selectSource(matchedSource.id, { type: "file", handles });
+  const handleFiles = useCallback(
+    async (files: File[]) => {
+      if (files.length === 0) {
+        return;
       }
-    },
-    [availableSources, enqueueSnackbar, installExtension, selectSource],
-  );
 
-  const openFiles = useCallback(
-    async (files: File[]) => {
       const otherFiles: File[] = [];
       log.debug("open files", files);
 
+      const data: Uint8Array[] = [];
       for (const file of files) {
         if (file.name.endsWith(".foxe")) {
-          // Extension installation
-          try {
-            const arrayBuffer = await file.arrayBuffer();
-            const data = new Uint8Array(arrayBuffer);
-            const extension = await installExtension("local", data);
-            enqueueSnackbar(`Installed extension ${extension.id}`, { variant: "success" });
-          } catch (err: unknown) {
-            log.error(err);
-            enqueueSnackbar(`Failed to install extension ${file.name}: ${(err as Error).message}`, {
-              variant: "error",
-            });
-          }
+          const arrayBuffer = await file.arrayBuffer();
path: packages/suite-base/src/Workspace.tsx
user: login: luluiz
body: done
created_at: 2025-03-18T09:18:39Z
author_association: MEMBER
diff_hunk: @@ -241,13 +241,17 @@ function WorkspaceContent(props: WorkspaceProps): React.JSX.Element {
       log.debug("open files", files);
 
       const data: Uint8Array[] = [];
-      for (const file of files) {
-        if (file.name.endsWith(".foxe")) {
-          const arrayBuffer = await file.arrayBuffer();
-          data.push(new Uint8Array(arrayBuffer));
-        } else {
-          otherFiles.push(file);
+      try {
path: packages/suite-base/src/Workspace.tsx
user: login: aneuwald-ctw
body: I would say to move them inside the for loop, so If one fail, the others will continue to load
created_at: 2025-03-18T09:19:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -241,13 +241,17 @@ function WorkspaceContent(props: WorkspaceProps): React.JSX.Element {
       log.debug("open files", files);
 
       const data: Uint8Array[] = [];
-      for (const file of files) {
-        if (file.name.endsWith(".foxe")) {
-          const arrayBuffer = await file.arrayBuffer();
-          data.push(new Uint8Array(arrayBuffer));
-        } else {
-          otherFiles.push(file);
+      try {
path: packages/suite-base/src/Workspace.tsx
user: login: luluiz
body: done
created_at: 2025-03-18T09:33:33Z
author_association: MEMBER
diff_hunk: @@ -456,6 +456,7 @@ export interface PanelSettings<ExtensionSettings> {
    */
   handler: (action: SettingsTreeAction, config?: ExtensionSettings) => void;
   defaultConfig?: ExtensionSettings;
+  extensionId?: string;
path: packages/suite/src/index.ts
user: login: aneuwald-ctw
body: we removed because users should't define it the registering a message converter
created_at: 2025-03-18T10:39:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -466,6 +467,7 @@ export type RegisterMessageConverterArgs<Src> = {
    * Custom settings for the topics using the schema specified in the *toSchemaName* property
    */
   panelSettings?: Record<string, PanelSettings<unknown>>;
+  extensionId?: string;
path: packages/suite/src/index.ts
user: login: aneuwald-ctw
body: we removed because users should't define it the registering a message converter
created_at: 2025-03-18T10:40:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-18T10:37:00Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=Lichtblick-Suite_lichtblick&pullRequest=389) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=Lichtblick-Suite_lichtblick&pullRequest=389&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [2 Accepted issues](https://sonarcloud.io/project/issues?id=Lichtblick-Suite_lichtblick&pullRequest=389&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=Lichtblick-Suite_lichtblick&pullRequest=389&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [80.2% Coverage on New Code](https://sonarcloud.io/component_measures?id=Lichtblick-Suite_lichtblick&pullRequest=389&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.1% Duplication on New Code](https://sonarcloud.io/component_measures?id=Lichtblick-Suite_lichtblick&pullRequest=389&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Lichtblick-Suite_lichtblick&pullRequest=389)


author_association: NONE