Repository: lichtblick-suite/lichtblick
Pull request #495: Adding readme and changelog to extension details
Author: login: ctw-joao-luis
Labels: name: enhancement
description: New feature or request
Created: 2025-05-06T15:49:56Z | Merged: 2025-05-09T14:16:25Z

**User-Facing Changes**
Lichtblick is now able to show both readme and changelog information related to the extensions installed locally, just like it did to the extensions installed via the Marketplace

![image](https://github.com/user-attachments/assets/7bdb5fc9-77f4-4ae8-b95b-1443b6ef4043)

![image](https://github.com/user-attachments/assets/6b0b0c02-75f5-4583-bbc7-69ffbc9f66e1)


**Description**
There were two main problems that Lichtblick had with this feature for extensions installed locally:
- First the type of objects the `ExtensionDetails` component was receiving to list the local extensions didn't have the `readme` and `changelog` attirbutes;
- Second was that the function that was returning the value of the `readme` and `changelog` was hardcoded to receive URLs, because that is how extensions from the marketplace pass their values. Therefore I need to change the function to work with getting values from an URL or from a file that's installed locally on `.lichtblick-suite/extensions` 

This PR resolves #404

<!-- 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


Linked issue #404: Extension: Readme and Changelog missing
Hi team,

my extension contains the README and CHANGLEOG in the .foxe archive but it is not displayed when loaded into Lichtblick:
https://github.com/Lichtblick-Suite/asam-osi-converter/releases/tag/v0.0.5

![Image](https://github.com/user-attachments/assets/5e2b4ac9-0647-42cc-a672-5521307e0466)
Linked issue comments: user: login: aneuwald-ctw
created_at: 2025-03-10T09:17:09Z
body: Hey @jdsika ,

Thanks for bringing this to our attention! This isn't an issue specific to your extension but rather a general limitation affecting all extensions. We've taken note of it and will handle it internally. We'll work on a fix as soon as we can.
author_association: CONTRIBUTOR
user: login: thomassedlmayer
created_at: 2025-03-12T15:33:16Z
body: @aneuwald-ctw Would it be already enough to merge this PR: https://github.com/Lichtblick-Suite/extension-registry/pull/1 ?

Does Lichtblick pull the readme/changelog information from the Lichtblick's extension-registry or from foxglove's extension-registry?
author_association: CONTRIBUTOR
user: login: aneuwald-ctw
created_at: 2025-04-21T10:32:04Z
body: Hi @thomassedlmayer,

No, that repository is no longer in use and has been archived — it’s an old artifact from the original fork. Currently, Lichtblick only parses and displays data from the Marketplace / Extension Registry.

We’ve already opened an internal issue to address this behavior, and it will be resolved in the future.

Best regards,
Alex
author_association: CONTRIBUTOR

Diff:
diff --git a/packages/suite-base/package.json b/packages/suite-base/package.json
index 2dfb0860de..cf42ebc584 100644
--- a/packages/suite-base/package.json
+++ b/packages/suite-base/package.json
@@ -107,6 +107,7 @@
     "css-loader": "6.8.1",
     "cytoscape": "3.31.1",
     "cytoscape-dagre": "2.5.0",
+    "dompurify": "3.2.5",
     "esbuild-loader": "2.21.0",
     "eventemitter3": "5.0.1",
     "fake-indexeddb": "4.0.2",
diff --git a/packages/suite-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx b/packages/suite-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx
index bd6daf366a..cc24b82d9c 100644
--- a/packages/suite-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx
+++ b/packages/suite-base/src/components/AppSettingsDialog/AppSettingsDialog.stories.tsx
@@ -30,6 +30,8 @@ const installedExtensions: ExtensionInfo[] = _.range(1, 10).map((index) => ({
   version: `1.${index}`,
   keywords: ["storybook", "testing"],
   namespace: index % 2 === 0 ? "local" : "org",
+  readme: "Readme for extension",
+  changelog: "Changes effectuated on extension",
 }));
 
 const marketplaceExtensions: ExtensionInfo[] = [
@@ -44,6 +46,8 @@ const marketplaceExtensions: ExtensionInfo[] = [
     license: "MIT",
     version: "1.2.10",
     keywords: ["storybook", "testing"],
+    readme: "Information about extension",
+    changelog: "Some changes on extension",
   },
 ];
 
diff --git a/packages/suite-base/src/components/ExtensionDetails.stories.tsx b/packages/suite-base/src/components/ExtensionDetails.stories.tsx
index 5a543861f9..201990d1e8 100644
--- a/packages/suite-base/src/components/ExtensionDetails.stories.tsx
+++ b/packages/suite-base/src/components/ExtensionDetails.stories.tsx
@@ -60,6 +60,7 @@ const extension: ExtensionMarketplaceDetail = {
   license: "MIT",
   version: "1.2.10",
   keywords: ["storybook", "testing"],
+  displayName: "Display Extension Name",
   time: {
     modified: "2021-05-19T21:37:40.166Z",
     created: "2012-04-17T00:38:04.350Z",
diff --git a/packages/suite-base/src/components/ExtensionDetails.test.tsx b/packages/suite-base/src/components/ExtensionDetails.test.tsx
index 5bdaef8d7a..d9f3a4bfae 100644
--- a/packages/suite-base/src/components/ExtensionDetails.test.tsx
+++ b/packages/suite-base/src/components/ExtensionDetails.test.tsx
@@ -8,7 +8,6 @@ import "@testing-library/jest-dom";
 import { render, screen, fireEvent, waitFor } from "@testing-library/react";
 import { useSnackbar } from "notistack";
 
-import { Immutable } from "@lichtblick/suite/src/immutable";
 import { ExtensionDetails } from "@lichtblick/suite-base/components/ExtensionDetails";
 import { useAnalytics } from "@lichtblick/suite-base/context/AnalyticsContext";
 import { useExtensionCatalog } from "@lichtblick/suite-base/context/ExtensionCatalogContext";
@@ -17,6 +16,7 @@ import {
   useExtensionMarketplace,
 } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext";
 import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder";
+import ExtensionBuilder from "@lichtblick/suite-base/testing/builders/ExtensionBuilder";
 import isDesktopApp from "@lichtblick/suite-base/util/isDesktopApp";
 
 jest.mock("notistack", () => ({
@@ -45,20 +45,9 @@ describe("ExtensionDetails Component", () => {
   const mockUninstallExtension = jest.fn();
   const mockGetMarkdown = jest.fn();
 
-  const mockExtension: Immutable<ExtensionMarketplaceDetail> = {
-    id: BasicBuilder.string(),
-    name: BasicBuilder.string(),
-    qualifiedName: BasicBuilder.string(),
-    description: BasicBuilder.string(),
-    publisher: BasicBuilder.string(),
-    homepage: BasicBuilder.string(),
-    license: BasicBuilder.string(),
-    version: BasicBuilder.string(),
-    readme: BasicBuilder.string(),
-    changelog: BasicBuilder.string(),
-    foxe: BasicBuilder.string(),
+  const mockExtension: ExtensionMarketplaceDetail = ExtensionBuilder.extensionMarketplaceDetail({
     namespace: "local",
-  };
+  });
 
   beforeEach(() => {
     (useSnackbar as jest.Mock).mockReturnValue({ enqueueSnackbar: mockEnqueueSnackbar });
@@ -150,6 +139,40 @@ describe("ExtensionDetails Component", () => {
       });
     });
 
+    it("displays readme correctly", async () => {
+      (isDesktopApp as jest.Mock).mockReturnValue(true);
+
+      const readmeContent = BasicBuilder.string();
+      mockExtension.readme = readmeContent;
+
+      render(<ExtensionDetails extension={mockExtension} onClose={() => {}} installed={false} />);
+      const readmeButton = screen.getByRole("tab", {
+        name: /readme/i,
+      });
+
+      fireEvent.click(readmeButton);
+      await waitFor(() => {
+        expect(screen.getByText(readmeContent)).toBeInTheDocument();
+      });
+    });
+
+    it("displays changelog correctly", async () => {
+      (isDesktopApp as jest.Mock).mockReturnValue(true);
+
+      const changelogContent = BasicBuilder.string();
+      mockExtension.changelog = changelogContent;
+
+      render(<ExtensionDetails extension={mockExtension} onClose={() => {}} installed={false} />);
+      const changelogButton = screen.getByRole("tab", {
+        name: /changelog/i,
+      });
+
+      fireEvent.click(changelogButton);
+      await waitFor(() => {
+        expect(screen.getByText(changelogContent)).toBeInTheDocument();
+      });
+    });
+
     it("displays an error message when the download and install process fails on desktop app", async () => {
       (isDesktopApp as jest.Mock).mockReturnValue(true);
 
diff --git a/packages/suite-base/src/components/ExtensionDetails.tsx b/packages/suite-base/src/components/ExtensionDetails.tsx
index f8df353f13..e0be45dab5 100644
--- a/packages/suite-base/src/components/ExtensionDetails.tsx
+++ b/packages/suite-base/src/components/ExtensionDetails.tsx
@@ -7,6 +7,7 @@
 
 import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
 import { Button, Link, Tab, Tabs, Typography, Divider } from "@mui/material";
+import DOMPurify from "dompurify";
 import { useSnackbar } from "notistack";
 import { useCallback, useState } from "react";
 import { useAsync, useMountedState } from "react-use";
@@ -23,6 +24,7 @@ import {
 } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext";
 import { AppEvent } from "@lichtblick/suite-base/services/IAnalytics";
 import isDesktopApp from "@lichtblick/suite-base/util/isDesktopApp";
+import { isValidUrl } from "@lichtblick/suite-base/util/isValidURL";
 
 type Props = {
   installed: boolean;
@@ -56,7 +58,11 @@ enum OperationStatus {
  * @param {Function} props.onClose - Callback function to close the details view.
  * @returns {React.ReactElement} The rendered component.
  */
-export function ExtensionDetails({ extension, onClose, installed }: Props): React.ReactElement {
+export function ExtensionDetails({
+  extension,
+  onClose,
+  installed,
+}: Readonly<Props>): React.ReactElement {
   const { classes } = useStyles();
   const [isInstalled, setIsInstalled] = useState(installed);
   const [operationStatus, setOperationStatus] = useState<OperationStatus>(OperationStatus.IDLE);
@@ -67,18 +73,24 @@ export function ExtensionDetails({ extension, onClose, installed }: Props): Reac
   const uninstallExtension = useExtensionCatalog((state) => state.uninstallExtension);
   const marketplace = useExtensionMarketplace();
   const { enqueueSnackbar } = useSnackbar();
-  const readmeUrl = extension.readme;
-  const changelogUrl = extension.changelog;
+  const readme = extension.readme;
+  const changelog = extension.changelog;
   const canInstall = extension.foxe != undefined;
   const canUninstall = extension.namespace !== "org";
 
   const { value: readmeContent } = useAsync(
-    async () => (readmeUrl != undefined ? await marketplace.getMarkdown(readmeUrl) : ""),
-    [marketplace, readmeUrl],
+    async () =>
+      readme != undefined && isValidUrl(readme)
+        ? await marketplace.getMarkdown(readme)
+        : DOMPurify.sanitize(readme ?? ""),
+    [marketplace, readme],
   );
   const { value: changelogContent } = useAsync(
-    async () => (changelogUrl != undefined ? await marketplace.getMarkdown(changelogUrl) : ""),
-    [marketplace, changelogUrl],
+    async () =>
+      changelog != undefined && isValidUrl(changelog)
+        ? await marketplace.getMarkdown(changelog)
+        : DOMPurify.sanitize(changelog ?? ""),
+    [marketplace, changelog],
   );
 
   const analytics = useAnalytics();
diff --git a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensioList.test.tsx b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensioList.test.tsx
index 3c02175310..56acf155c0 100644
--- a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensioList.test.tsx
+++ b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionList/ExtensioList.test.tsx
@@ -55,6 +55,8 @@ describe("ExtensionList Component", () => {
       qualifiedName: "org.extension1",
       homepage: BasicBuilder.string(),
       license: BasicBuilder.string(),
+      displayName: "DisplayName-Extension 1",
+      keywords: [BasicBuilder.string()],
     },
     {
       id: "2",
@@ -65,6 +67,8 @@ describe("ExtensionList Component", () => {
       qualifiedName: "org.extension2",
       homepage: BasicBuilder.string(),
       license: BasicBuilder.string(),
+      displayName: "DisplayName-Extension 2",
+      keywords: [BasicBuilder.string()],
     },
   ];
   const mockFilterText = "Extension";
diff --git a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.test.tsx b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.test.tsx
index 807c4286e0..7a191ae7c0 100644
--- a/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.test.tsx
+++ b/packages/suite-base/src/components/ExtensionsSettings/components/ExtensionListEntry/ExtensionListEntry.test.tsx
@@ -3,25 +3,17 @@
 // SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
 // SPDX-License-Identifier: MPL-2.0
 import { render, screen, fireEvent } from "@testing-library/react";
+import "@testing-library/jest-dom";
 
 import { Immutable } from "@lichtblick/suite";
 import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext";
-import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder";
-import "@testing-library/jest-dom";
+import ExtensionBuilder from "@lichtblick/suite-base/testing/builders/ExtensionBuilder";
 
 import ExtensionListEntry from "./ExtensionListEntry";
 
 describe("ExtensionListEntry Component", () => {
-  const mockEntry: Immutable<ExtensionMarketplaceDetail> = {
-    id: BasicBuilder.string(),
-    name: BasicBuilder.string(),
-    qualifiedName: BasicBuilder.string(),
-    description: BasicBuilder.string(),
-    publisher: BasicBuilder.string(),
-    homepage: BasicBuilder.string(),
-    license: BasicBuilder.string(),
-    version: BasicBuilder.string(),
-  };
+  const mockEntry: Immutable<ExtensionMarketplaceDetail> =
+    ExtensionBuilder.extensionMarketplaceDetail();
 
   const mockOnClick = jest.fn();
 
diff --git a/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.ts b/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.ts
index e1ade22846..76d4c6dae5 100644
--- a/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.ts
+++ b/packages/suite-base/src/components/ExtensionsSettings/hooks/useExtensionSettings.ts
@@ -64,6 +64,8 @@ const useExtensionSettings = (): UseExtensionSettingsHook => {
         keywords: entry.keywords,
         namespace: entry.namespace,
         qualifiedName: entry.qualifiedName,
+        readme: entry.readme,
+        changelog: entry.changelog,
       };
     });
   }, [installed, marketplaceMap]);
diff --git a/packages/suite-base/src/components/ExtensionsSettings/index.stories.tsx b/packages/suite-base/src/components/ExtensionsSettings/index.stories.tsx
index 9d8091ff0d..7b14c40ab4 100644
--- a/packages/suite-base/src/components/ExtensionsSettings/index.stories.tsx
+++ b/packages/suite-base/src/components/ExtensionsSettings/index.stories.tsx
@@ -44,6 +44,8 @@ const installedExtensions: ExtensionInfo[] = [
     version: "1.2.10",
     keywords: ["storybook", "testing"],
     namespace: "org",
+    readme: "Readme for extension",
+    changelog: "Changes effectuated on extension",
   },
   {
     id: "publisher.storyextension",
@@ -57,6 +59,8 @@ const installedExtensions: ExtensionInfo[] = [
     version: "1.2.10",
     keywords: ["storybook", "testing"],
     namespace: "local",
+    readme: "Readme for extension",
+    changelog: "Changes effectuated on extension",
   },
 ];
 
@@ -72,6 +76,8 @@ const marketplaceExtensions: ExtensionInfo[] = [
     license: "MIT",
     version: "1.2.10",
     keywords: ["storybook", "testing"],
+    readme: "Readme for extension",
+    changelog: "Changes effectuated on extension",
   },
 ];
 
diff --git a/packages/suite-base/src/context/ExtensionMarketplaceContext.ts b/packages/suite-base/src/context/ExtensionMarketplaceContext.ts
index a610c1e494..0949151950 100644
--- a/packages/suite-base/src/context/ExtensionMarketplaceContext.ts
+++ b/packages/suite-base/src/context/ExtensionMarketplaceContext.ts
@@ -7,23 +7,11 @@
 
 import { createContext, useContext } from "react";
 
-import { ExtensionNamespace } from "@lichtblick/suite-base/types/Extensions";
-
-export type ExtensionMarketplaceDetail = {
-  id: string;
-  name: string;
-  qualifiedName: string;
-  namespace?: ExtensionNamespace;
-  description: string;
-  publisher: string;
-  homepage: string;
-  license: string;
-  version: string;
-  readme?: string;
-  changelog?: string;
+import { ExtensionInfo } from "@lichtblick/suite-base/types/Extensions";
+
+export type ExtensionMarketplaceDetail = ExtensionInfo & {
   sha256sum?: string;
   foxe?: string;
-  keywords?: string[];
   time?: Record<string, string>;
 };
 
diff --git a/packages/suite-base/src/hooks/useInstallingExtensionsState.tsx b/packages/suite-base/src/hooks/useInstallingExtensionsState.tsx
index f2eeb00fa5..cf612d0455 100644
--- a/packages/suite-base/src/hooks/useInstallingExtensionsState.tsx
+++ b/packages/suite-base/src/hooks/useInstallingExtensionsState.tsx
@@ -65,7 +65,6 @@ export function useInstallingExtensionsState({
         for (let i = 0; i < extensionsData.length; i += INSTALL_EXTENSIONS_BATCH) {
           const chunk = extensionsData.slice(i, i + INSTALL_EXTENSIONS_BATCH);
           const result = await installExtensions("local", chunk);
-
           const installedCount = result.filter(({ success }) => success).length;
           setInstallingProgress((prev) => ({
             ...prev,
diff --git a/packages/suite-base/src/panels/ThreeDeeRender/renderables/Urdfs.ts b/packages/suite-base/src/panels/ThreeDeeRender/renderables/Urdfs.ts
index 6badfdd2a0..33c008b98f 100644
--- a/packages/suite-base/src/panels/ThreeDeeRender/renderables/Urdfs.ts
+++ b/packages/suite-base/src/panels/ThreeDeeRender/renderables/Urdfs.ts
@@ -30,6 +30,7 @@ import {
 import { makeRgba, stringToRgba } from "@lichtblick/suite-base/panels/ThreeDeeRender/color";
 import { eulerToQuaternion } from "@lichtblick/suite-base/util/geometry";
 import isDesktopApp from "@lichtblick/suite-base/util/isDesktopApp";
+import { isValidUrl } from "@lichtblick/suite-base/util/isValidURL";
 
 import { RenderableCube } from "./markers/RenderableCube";
 import { RenderableCylinder } from "./markers/RenderableCylinder";
@@ -1173,18 +1174,6 @@ function createMeshMarker(
   };
 }
 
-const VALID_PROTOCOLS = ["https:", "http:", "file:", "data:", "package:"];
-
-function isValidUrl(str: string): boolean {
-  try {
-    const url = new URL(str);
-    return VALID_PROTOCOLS.includes(url.protocol);
-  } catch (err: unknown) {
-    console.error(err);
-    return false;
-  }
-}
-
 function urdfChildren(
   transforms: TransformData[] | undefined,
   transformTree: TransformTree,
diff --git a/packages/suite-base/src/providers/ExtensionCatalogProvider.test.tsx b/packages/suite-base/src/providers/ExtensionCatalogProvider.test.tsx
index 1111943b74..4139293a64 100644
--- a/packages/suite-base/src/providers/ExtensionCatalogProvider.test.tsx
+++ b/packages/suite-base/src/providers/ExtensionCatalogProvider.test.tsx
@@ -27,7 +27,7 @@ import ExtensionCatalogProvider from "./ExtensionCatalogProvider";
 
 describe("ExtensionCatalogProvider", () => {
   function setup({ loadersOverride }: { loadersOverride?: ExtensionLoader[] } = {}) {
-    const extensionInfo: ExtensionInfo = ExtensionBuilder.extension();
+    const extensionInfo: ExtensionInfo = ExtensionBuilder.extensionInfo();
     const extensions: ExtensionInfo[] = [extensionInfo];
 
     const loadExtension = jest
@@ -68,8 +68,8 @@ describe("ExtensionCatalogProvider", () => {
   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 extension1 = ExtensionBuilder.extension({ namespace: "org" });
-    const extension2 = ExtensionBuilder.extension({ namespace: "local" });
+    const extension1 = ExtensionBuilder.extensionInfo({ namespace: "org" });
+    const extension2 = ExtensionBuilder.extensionInfo({ namespace: "local" });
     const loadExtension1 = jest.fn().mockResolvedValue(source1);
     const loadExtension2 = jest.fn().mockResolvedValue(source2);
     const loader1: ExtensionLoader = {
@@ -112,7 +112,7 @@ describe("ExtensionCatalogProvider", () => {
         }
     `;
     const loadExtension = jest.fn().mockResolvedValue(source);
-    const extension = ExtensionBuilder.extension();
+    const extension = ExtensionBuilder.extensionInfo();
     const loader: ExtensionLoader = {
       namespace: extension.namespace!,
       getExtension: jest.fn(),
@@ -167,7 +167,7 @@ describe("ExtensionCatalogProvider", () => {
             }
         }
     `;
-    const extension = ExtensionBuilder.extension();
+    const extension = ExtensionBuilder.extensionInfo();
     const loadExtension = jest.fn().mockResolvedValue(source);
     const loader: ExtensionLoader = {
       namespace: extension.namespace!,
@@ -205,7 +205,7 @@ describe("ExtensionCatalogProvider", () => {
         }
     `;
     const loadExtension = jest.fn().mockResolvedValue(source);
-    const extension = ExtensionBuilder.extension();
+    const extension = ExtensionBuilder.extensionInfo();
     const loader: ExtensionLoader = {
       namespace: extension.namespace!,
       getExtension: jest.fn(),
diff --git a/packages/suite-base/src/providers/helpers/buildContributionPoints.test.ts b/packages/suite-base/src/providers/helpers/buildContributionPoints.test.ts
index 67b861e4a3..1cc8fd4db6 100644
--- a/packages/suite-base/src/providers/helpers/buildContributionPoints.test.ts
+++ b/packages/suite-base/src/providers/helpers/buildContributionPoints.test.ts
@@ -15,7 +15,7 @@ describe("buildContributionPoints", () => {
 
   it("should initialize contribution objects", () => {
     const consoleErrorMock = jest.spyOn(console, "error").mockImplementation(() => {});
-    const extensionInfo = ExtensionBuilder.extension();
+    const extensionInfo = ExtensionBuilder.extensionInfo();
 
     const result = buildContributionPoints(extensionInfo, "");
 
@@ -27,7 +27,7 @@ describe("buildContributionPoints", () => {
   });
 
   it("should register a panel", () => {
-    const extensionInfo = ExtensionBuilder.extension();
+    const extensionInfo = ExtensionBuilder.extensionInfo();
     const panelName = BasicBuilder.string();
     const panelId = `${extensionInfo.qualifiedName}.${panelName}`;
     const registration: ExtensionPanelRegistration = {
@@ -63,7 +63,7 @@ describe("buildContributionPoints", () => {
 
   it("should warn when trying to register a duplicate panel", () => {
     const logWarnMock = jest.spyOn(console, "warn").mockImplementation(() => {});
-    const extensionInfo = ExtensionBuilder.extension();
+    const extensionInfo = ExtensionBuilder.extensionInfo();
     const panelName = BasicBuilder.string();
     const panelId = `${extensionInfo.qualifiedName}.${panelName}`;
     const registration: ExtensionPanelRegistration = {
@@ -92,7 +92,7 @@ describe("buildContributionPoints", () => {
   });
 
   it("should register a message converter", () => {
-    const extensionInfo = ExtensionBuilder.extension();
+    const extensionInfo = ExtensionBuilder.extensionInfo();
     const messageConverter: MessageConverter = {
       fromSchemaName: BasicBuilder.string(),
       toSchemaName: BasicBuilder.string(),
@@ -123,7 +123,7 @@ describe("buildContributionPoints", () => {
   });
 
   it("should register a message converter with panel settings", () => {
-    const extensionInfo = ExtensionBuilder.extension();
+    const extensionInfo = ExtensionBuilder.extensionInfo();
     const panelSettingsA: PanelSettings<unknown> = {
       defaultConfig: BasicBuilder.genericDictionary(String),
       handler: jest.fn(),
@@ -171,7 +171,7 @@ describe("buildContributionPoints", () => {
   });
 
   it("registers topic aliases correctly", () => {
-    const extensionInfo = ExtensionBuilder.extension();
+    const extensionInfo = ExtensionBuilder.extensionInfo();
     const aliasFunction: TopicAliasFunction = jest.fn();
 
     (globalThis as any).topicAliasFunction = aliasFunction;
diff --git a/packages/suite-base/src/services/IdbExtensionLoader.test.ts b/packages/suite-base/src/services/IdbExtensionLoader.test.ts
index e1366d3e12..3c7f9d499d 100644
--- a/packages/suite-base/src/services/IdbExtensionLoader.test.ts
+++ b/packages/suite-base/src/services/IdbExtensionLoader.test.ts
@@ -47,6 +47,18 @@ const packageJson: any = {
   version: "0.0.1",
 };
 
+const expectedReadme = "# studio-extension-turtlesim\n\n## _A Foxglove Studio Extension_\n";
+const expectedChangelog =
+  "# studio-extension-turtlesim version history\n\n## 0.0.0\n\n- Alpha testing\n";
+
+const expectedExtensionInfo: ExtensionInfo = {
+  ...packageJson,
+  namespace: "local",
+  qualifiedName: "turtlesim",
+  readme: expectedReadme,
+  changelog: expectedChangelog,
+} as ExtensionInfo;
+
 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`;
 
@@ -78,29 +90,24 @@ describe("IdbExtensionLoader", () => {
 
     it("should install local extensions", async () => {
       const foxe = fs.readFileSync(EXT_FILE_TURTLESIM);
-      const info: ExtensionInfo = {
-        ...packageJson,
-        namespace: "local",
-        qualifiedName: "turtlesim",
-      } as ExtensionInfo;
       const loader = new IdbExtensionLoader("local");
 
       await loader.installExtension(foxe as unknown as Uint8Array);
 
-      expect(mockPut).toHaveBeenCalledWith(METADATA_STORE_NAME, info);
+      expect(mockPut).toHaveBeenCalledWith(METADATA_STORE_NAME, expectedExtensionInfo);
       expect(mockPut).toHaveBeenCalledWith(EXTENSION_STORE_NAME, {
         content: foxe,
-        info,
+        info: expectedExtensionInfo,
       });
     });
 
     it("should install private extensions", async () => {
       const foxe = fs.readFileSync(EXT_FILE_TURTLESIM);
       const info: ExtensionInfo = {
-        ...packageJson,
+        ...expectedExtensionInfo,
         namespace: "org",
         qualifiedName: "org:Foxglove Inc:studio-extension-turtlesim",
-      } as ExtensionInfo;
+      };
       mockGetAll.mockReturnValue([info]);
       const loader = new IdbExtensionLoader("org");
 
@@ -122,6 +129,8 @@ describe("IdbExtensionLoader", () => {
         namespace: "org",
         publisher: "Prefix",
         qualifiedName: "org:Prefix:package-name",
+        changelog: "",
+        readme: "",
       } as ExtensionInfo;
 
       mockGetAll.mockReturnValue([info]);
diff --git a/packages/suite-base/src/services/IdbExtensionLoader.ts b/packages/suite-base/src/services/IdbExtensionLoader.ts
index a05cf4bae0..332ab5a9e4 100644
--- a/packages/suite-base/src/services/IdbExtensionLoader.ts
+++ b/packages/suite-base/src/services/IdbExtensionLoader.ts
@@ -22,6 +22,8 @@ const log = Log.getLogger(__filename);
 export enum ALLOWED_FILES {
   EXTENSION = "dist/extension.js",
   PACKAGE = "package.json",
+  README = "README.md",
+  CHANGELOG = "CHANGELOG.md",
 }
 
 function parsePackageName(name: string): { publisher?: string; name: string } {
@@ -62,6 +64,17 @@ export function validatePackageInfo(info: Partial<ExtensionInfo>): ExtensionInfo
   return { ...info, publisher, name: name.toLowerCase() } as ExtensionInfo;
 }
 
+async function getFileContent(
+  foxeFileData: Uint8Array,
+  allowedFile: ALLOWED_FILES,
+): Promise<string | undefined> {
+  const zip = new JSZip();
+  const content = await zip.loadAsync(foxeFileData);
+  const extractedContent = await content.file(allowedFile)?.async("string");
+
+  return extractedContent;
+}
+
 export class IdbExtensionLoader implements ExtensionLoader {
   readonly #storage: IExtensionStorage;
   public readonly namespace: ExtensionNamespace;
@@ -104,13 +117,14 @@ export class IdbExtensionLoader implements ExtensionLoader {
   public async installExtension(foxeFileData: Uint8Array): Promise<ExtensionInfo> {
     log.debug("Installing extension");
 
-    const zip = new JSZip();
-    const content = await zip.loadAsync(foxeFileData);
-
-    const pkgInfoText = await content.file(ALLOWED_FILES.PACKAGE)?.async("string");
+    const pkgInfoText = await getFileContent(foxeFileData, ALLOWED_FILES.PACKAGE);
     if (!pkgInfoText) {
-      throw new Error(`Invalid extension: missing ${ALLOWED_FILES.PACKAGE}`);
+      throw new Error(
+        `Corrupted extension. File "${ALLOWED_FILES.PACKAGE}" is missing in the extension source.`,
+      );
     }
+    const readme = (await getFileContent(foxeFileData, ALLOWED_FILES.README)) ?? "";
+    const changelog = (await getFileContent(foxeFileData, ALLOWED_FILES.CHANGELOG)) ?? "";
 
     const rawInfo = validatePackageInfo(JSON.parse(pkgInfoText) as Partial<ExtensionInfo>);
     const normalizedPublisher = rawInfo.publisher.replace(/[^A-Za-z0-9_\s]+/g, "");
@@ -120,6 +134,8 @@ export class IdbExtensionLoader implements ExtensionLoader {
       id: `${normalizedPublisher}.${rawInfo.name}`,
       namespace: this.namespace,
       qualifiedName: qualifiedName(this.namespace, normalizedPublisher, rawInfo),
+      readme,
+      changelog,
     };
     await this.#storage.put({
       content: foxeFileData,
diff --git a/packages/suite-base/src/testing/builders/ExtensionBuilder.ts b/packages/suite-base/src/testing/builders/ExtensionBuilder.ts
index 611d600cfb..84cb388b34 100644
--- a/packages/suite-base/src/testing/builders/ExtensionBuilder.ts
+++ b/packages/suite-base/src/testing/builders/ExtensionBuilder.ts
@@ -1,12 +1,13 @@
 // SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
 // SPDX-License-Identifier: MPL-2.0
 
+import { ExtensionMarketplaceDetail } from "@lichtblick/suite-base/context/ExtensionMarketplaceContext";
 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 {
+  public static extensionInfo(props: Partial<ExtensionInfo> = {}): ExtensionInfo {
     return defaults<ExtensionInfo>(props, {
       description: BasicBuilder.string(),
       displayName: BasicBuilder.string(),
@@ -19,6 +20,19 @@ export default class ExtensionBuilder {
       publisher: BasicBuilder.string(),
       qualifiedName: BasicBuilder.string(),
       version: BasicBuilder.string(),
+      readme: BasicBuilder.string(),
+      changelog: BasicBuilder.string(),
+    });
+  }
+
+  public static extensionMarketplaceDetail(
+    props: Partial<ExtensionMarketplaceDetail> = {},
+  ): ExtensionMarketplaceDetail {
+    return defaults<ExtensionMarketplaceDetail>(props, {
+      ...this.extensionInfo(props),
+      foxe: BasicBuilder.string(),
+      sha256sum: BasicBuilder.string(),
+      time: BasicBuilder.genericDictionary(String),
     });
   }
 }
diff --git a/packages/suite-base/src/types/Extensions.ts b/packages/suite-base/src/types/Extensions.ts
index 62d363ee61..76d31eaa34 100644
--- a/packages/suite-base/src/types/Extensions.ts
+++ b/packages/suite-base/src/types/Extensions.ts
@@ -24,4 +24,6 @@ export type ExtensionInfo = {
   publisher: string;
   qualifiedName: string;
   version: string;
+  readme?: string;
+  changelog?: string;
 };
diff --git a/packages/suite-base/src/util/isValidURL.ts b/packages/suite-base/src/util/isValidURL.ts
new file mode 100644
index 0000000000..21e1bfe2cd
--- /dev/null
+++ b/packages/suite-base/src/util/isValidURL.ts
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
+// SPDX-License-Identifier: MPL-2.0
+
+const VALID_PROTOCOLS = ["https:", "http:", "file:", "data:", "package:"];
+
+export function isValidUrl(str: string): boolean {
+  try {
+    const url = new URL(str);
+    return VALID_PROTOCOLS.includes(url.protocol);
+  } catch {
+    return false;
+  }
+}
diff --git a/packages/suite-base/src/util/isValidUrl.test.ts b/packages/suite-base/src/util/isValidUrl.test.ts
new file mode 100644
index 0000000000..1ecf6320af
--- /dev/null
+++ b/packages/suite-base/src/util/isValidUrl.test.ts
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
+// SPDX-License-Identifier: MPL-2.0
+
+import { isValidUrl } from "./isValidURL";
+
+describe("isValidUrl", () => {
+  it("should return true for valid URLs with allowed protocols", () => {
+    expect(isValidUrl("https://example.com")).toBe(true);
+    expect(isValidUrl("http://example.com")).toBe(true);
+    expect(isValidUrl("file:///path/to/file")).toBe(true);
+    expect(isValidUrl("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==")).toBe(true);
+    expect(isValidUrl("package:example")).toBe(true);
+  });
+
+  it("should return false for URLs with disallowed protocols", () => {
+    expect(isValidUrl("ftp://example.com")).toBe(false);
+    expect(isValidUrl("mailto:someone@example.com")).toBe(false);
+    expect(isValidUrl("javascript:alert('XSS')")).toBe(false);
+  });
+
+  it("should return false for invalid URL strings", () => {
+    expect(isValidUrl("not-a-url")).toBe(false);
+    expect(isValidUrl("://missing-protocol.com")).toBe(false);
+    expect(isValidUrl("")).toBe(false);
+  });
+
+  it("should return false for undefined input", () => {
+    expect(isValidUrl(undefined as unknown as string)).toBe(false);
+  });
+});
diff --git a/packages/suite-desktop/src/common/types.ts b/packages/suite-desktop/src/common/types.ts
index ba7fbfaed6..d7fd8740cb 100644
--- a/packages/suite-desktop/src/common/types.ts
+++ b/packages/suite-desktop/src/common/types.ts
@@ -68,6 +68,8 @@ type DesktopExtension = {
   id: string;
   packageJson: unknown;
   directory: string;
+  readme: string;
+  changelog: string;
 };
 
 type DesktopLayout = {
diff --git a/packages/suite-desktop/src/preload/extensions.test.ts b/packages/suite-desktop/src/preload/extensions.test.ts
new file mode 100644
index 0000000000..055f58431e
--- /dev/null
+++ b/packages/suite-desktop/src/preload/extensions.test.ts
@@ -0,0 +1,359 @@
+// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
+// SPDX-License-Identifier: MPL-2.0
+
+import { existsSync } from "fs";
+import { readdir, readFile, mkdir, rm, writeFile } from "fs/promises";
+import JSZip from "jszip";
+import { join as pathJoin } from "path";
+import randomString from "randomstring";
+
+import {
+  getExtensions,
+  getPackageDirname,
+  getPackageId,
+  installExtension,
+  parsePackageName,
+} from "./extensions";
+import { ExtensionPackageJson } from "./types";
+
+jest.mock("fs", () => ({
+  existsSync: jest.fn(),
+}));
+
+jest.mock("fs/promises", () => ({
+  readdir: jest.fn(),
+  readFile: jest.fn(),
+  mkdir: jest.fn(),
+  rm: jest.fn(),
+  writeFile: jest.fn(),
+}));
+
+jest.mock("jszip", () => ({
+  loadAsync: jest.fn(),
+}));
+
+const genericString = (): string =>
+  randomString.generate({ length: 6, charset: "alphanumeric", capitalization: "lowercase" });
+
+function generateExtensionPackageJSon({
+  name,
+  version,
+  main,
+  publisher,
+}: Partial<ExtensionPackageJson> = {}): ExtensionPackageJson {
+  return {
+    name: name ?? genericString(),
+    version: version ?? genericString(),
+    main: main ?? genericString(),
+    publisher,
+  };
+}
+
+describe("getPackageId", () => {
+  it("should throw an error if package.json is undefined", () => {
+    const undefinedPackageJson = undefined;
+
+    const result = () => {
+      getPackageId(undefinedPackageJson);
+    };
+
+    expect(result).toThrow(`Missing package.json`);
+  });
+
+  it("should throw an error if package.json is missing name field", () => {
+    const invalidPackageJson: Partial<ExtensionPackageJson> = {
+      version: genericString(),
+      main: genericString(),
+      publisher: genericString(),
+    };
+
+    const result = () => {
+      getPackageId(invalidPackageJson as ExtensionPackageJson);
+    };
+
+    expect(result).toThrow(`package.json is missing required "name" field`);
+  });
+
+  it("should throw an error if package.json is missing version field", () => {
+    const invalidPackageJson: Partial<ExtensionPackageJson> = {
+      name: genericString(),
+      main: genericString(),
+      publisher: genericString(),
+    };
+
+    const result = () => {
+      getPackageId(invalidPackageJson as ExtensionPackageJson);
+    };
+
+    expect(result).toThrow(`package.json is missing required "version" field`);
+  });
+
+  it("should throw an error if package.json is missing publisher field", () => {
+    const invalidPackageJson: Partial<ExtensionPackageJson> = generateExtensionPackageJSon();
+
+    const result = () => {
+      getPackageId(invalidPackageJson as ExtensionPackageJson);
+    };
+
+    expect(result).toThrow(`package.json is missing required "publisher" field`);
+  });
+
+  it("should throw an error if package.json contains an invalid publisher", () => {
+    const invalidPackageJson: Partial<ExtensionPackageJson> = generateExtensionPackageJSon({
+      publisher: "-----",
+    });
+
+    const result = () => {
+      getPackageId(invalidPackageJson as ExtensionPackageJson);
+    };
+
+    expect(result).toThrow(`package.json contains an invalid "publisher" field`);
+  });
+
+  it("should return an identifier with package publisher and name", () => {
+    const publisher = "lichtblick";
+    const mockPakageJson = generateExtensionPackageJSon({ publisher });
+
+    const result = getPackageId(mockPakageJson);
+
+    expect(result).toBe(`lichtblick.${mockPakageJson.name}`);
+  });
+});
+
+describe("getPackageDirname", () => {
+  const mockPakageJson = generateExtensionPackageJSon({ publisher: genericString() });
+  it("should return a valid directory name for a valid package.json", () => {
+    const result = getPackageDirname(mockPakageJson);
+
+    expect(result).toBe(
+      `${mockPakageJson.publisher}.${mockPakageJson.name}-${mockPakageJson.version}`,
+    );
+  });
+
+  it("should throw an error if the directory name exceeds 255 characters", () => {
+    mockPakageJson.name = genericString().repeat(50);
+    mockPakageJson.publisher = genericString().repeat(100);
+
+    expect(() => getPackageDirname(mockPakageJson)).toThrow(
+      "package.json publisher.name-version is too long",
+    );
+  });
+});
+
+describe("parsePackageName", () => {
+  it("should return the namespace and name for a scoped package name", () => {
+    const mockName = "@lichtblick-namespace/extension-package";
+    const result = parsePackageName(mockName);
+
+    expect(result).toEqual({
+      namespace: "lichtblick-namespace",
+      name: "extension-package",
+    });
+  });
+
+  it("should return only the name if the package name is not scoped", () => {
+    const mockName = "extension-package";
+    const result = parsePackageName(mockName);
+
+    expect(result).toEqual({
+      name: "extension-package",
+    });
+  });
+
+  it("should handle invalid package names gracefully", () => {
+    const result = parsePackageName("");
+
+    expect(result).toEqual({
+      name: "",
+    });
+  });
+});
+
+describe("getExtensions", () => {
+  const mockRootFolder = "/mock/extensions";
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it("should return an empty array if the root folder does not exist", async () => {
+    (existsSync as jest.Mock).mockReturnValue(false);
+
+    const result = await getExtensions(mockRootFolder);
+
+    expect(result).toEqual([]);
+    expect(existsSync).toHaveBeenCalledWith(mockRootFolder);
+  });
+
+  it("should return an empty array if the root folder is empty", async () => {
+    (existsSync as jest.Mock).mockReturnValue(true);
+    (readdir as jest.Mock).mockResolvedValue([]);
+
+    const result = await getExtensions(mockRootFolder);
+
+    expect(result).toEqual([]);
+    expect(readdir).toHaveBeenCalledWith(mockRootFolder, { withFileTypes: true });
+  });
+
+  it("should skip all entries when isDirectory is false", async () => {
+    (existsSync as jest.Mock).mockReturnValue(true);
+    (readdir as jest.Mock).mockResolvedValue([{ isDirectory: () => false }]);
+
+    const result = await getExtensions(mockRootFolder);
+
+    expect(result).toEqual([]);
+    expect(readdir).toHaveBeenCalledWith(mockRootFolder, { withFileTypes: true });
+  });
+
+  it("should load extensions from valid directories", async () => {
+    const mockPackageJson = generateExtensionPackageJSon({ publisher: genericString() });
+
+    (existsSync as jest.Mock).mockReturnValue(true);
+    (readdir as jest.Mock).mockResolvedValue([
+      { name: "extension1", isDirectory: () => true },
+      { name: "extension2", isDirectory: () => true },
+    ]);
+    (readFile as jest.Mock).mockImplementation(async (path: string) => {
+      if (path.endsWith("package.json")) {
+        return await Promise.resolve(JSON.stringify(mockPackageJson));
+      }
+      return await Promise.resolve("");
+    });
+
+    const result = await getExtensions(mockRootFolder);
+
+    expect(result).toHaveLength(2);
+    expect(result[0]).toMatchObject({
+      id: `${mockPackageJson.publisher}.${mockPackageJson.name}`,
+      packageJson: mockPackageJson,
+      directory: `${mockRootFolder}/extension1`,
+      readme: "",
+      changelog: "",
+    });
+    expect(result[1]).toMatchObject({
+      id: `${mockPackageJson.publisher}.${mockPackageJson.name}`,
+      packageJson: mockPackageJson,
+      directory: `${mockRootFolder}/extension2`,
+      readme: "",
+      changelog: "",
+    });
+  });
+
+  it("should handle errors gracefully and continue processing other extensions with README and CHANGELOG", async () => {
+    const mockPackageJson = generateExtensionPackageJSon({ publisher: genericString() });
+    const mockReadmeContent = genericString();
+    const mockChangelogContent = genericString();
+
+    (existsSync as jest.Mock).mockReturnValue(true);
+    (readdir as jest.Mock).mockResolvedValue([
+      { name: "extension1", isDirectory: () => true },
+      { name: "extension2", isDirectory: () => true },
+    ]);
+    (readFile as jest.Mock).mockImplementation(async (path: string) => {
+      if (path.includes("extension1")) {
+        if (path.endsWith("README.md")) {
+          return await Promise.resolve(mockReadmeContent);
+        }
+        if (path.endsWith("CHANGELOG.md")) {
+          return await Promise.resolve(mockChangelogContent);
+        }
+        throw new Error("Failed to read package.json");
+      }
+      if (path.endsWith("package.json")) {
+        return await Promise.resolve(JSON.stringify(mockPackageJson));
+      }
+      if (path.endsWith("README.md")) {
+        return await Promise.resolve(mockReadmeContent);
+      }
+      if (path.endsWith("CHANGELOG.md")) {
+        return await Promise.resolve(mockChangelogContent);
+      }
+      return await Promise.resolve("");
+    });
+
+    const result = await getExtensions(mockRootFolder);
+
+    expect(result).toHaveLength(1);
+    expect(result[0]).toMatchObject({
+      id: `${mockPackageJson.publisher}.${mockPackageJson.name}`,
+      packageJson: mockPackageJson,
+      directory: `${mockRootFolder}/extension2`,
+      readme: mockReadmeContent,
+      changelog: mockChangelogContent,
+    });
+    (console.error as jest.Mock).mockClear();
+  });
+});
+
+describe("installExtension", () => {
+  const mockRootFolder = "/mock/extensions";
+  const mockPackageJson = generateExtensionPackageJSon({ publisher: genericString() });
+  const mockReadmeContent = genericString();
+  const mockChangelogContent = genericString();
+
+  let mockArchive: any;
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+
+    mockArchive = {
+      files: {
+        "package.json": {
+          async: jest.fn().mockResolvedValue(JSON.stringify(mockPackageJson)),
+        },
+        "README.md": {
+          async: jest.fn().mockResolvedValue(mockReadmeContent),
+        },
+        "CHANGELOG.md": {
+          async: jest.fn().mockResolvedValue(mockChangelogContent),
+        },
+        "file.txt": {
+          async: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
+        },
+      },
+    };
+
+    (JSZip.loadAsync as jest.Mock).mockResolvedValue(mockArchive);
+  });
+
+  it("should install an extension successfully", async () => {
+    const result = await installExtension(new Uint8Array([1, 2, 3]), mockRootFolder);
+
+    expect(result).toMatchObject({
+      id: `${mockPackageJson.publisher}.${mockPackageJson.name}`,
+      packageJson: mockPackageJson,
+      directory: expect.stringContaining(`${mockPackageJson.publisher}.${mockPackageJson.name}`),
+      readme: mockReadmeContent,
+      changelog: mockChangelogContent,
+    });
+
+    const expectedDir = pathJoin(
+      mockRootFolder,
+      `${mockPackageJson.publisher}.${mockPackageJson.name}-${mockPackageJson.version}`,
+    );
+    expect(rm).toHaveBeenCalledWith(expectedDir, { recursive: true, force: true });
+    expect(mkdir).toHaveBeenCalledWith(expectedDir, { recursive: true });
+    expect(writeFile).toHaveBeenCalledWith(
+      pathJoin(expectedDir, "file.txt"),
+      expect.any(Uint8Array),
+    );
+  });
+
+  it("should throw an error if package.json is missing", async () => {
+    delete mockArchive.files["package.json"];
+
+    await expect(installExtension(new Uint8Array([1, 2, 3]), mockRootFolder)).rejects.toThrow(
+      "Extension does not contain a package.json file",
+    );
+  });
+
+  it("should throw an error if package.json is invalid", async () => {
+    mockArchive.files["package.json"].async.mockResolvedValue("invalid-json");
+
+    await expect(installExtension(new Uint8Array([1, 2, 3]), mockRootFolder)).rejects.toThrow(
+      "Extension contains an invalid package.json",
+    );
+
+    (console.error as jest.Mock).mockClear();
+  });
+});
diff --git a/packages/suite-desktop/src/preload/extensions.ts b/packages/suite-desktop/src/preload/extensions.ts
index a6378ab616..f6e9131640 100644
--- a/packages/suite-desktop/src/preload/extensions.ts
+++ b/packages/suite-desktop/src/preload/extensions.ts
@@ -12,12 +12,11 @@ import { dirname, join as pathJoin } from "path";
 
 import Logger from "@lichtblick/log";
 
+import { ExtensionPackageJson } from "./types";
 import { DesktopExtension } from "../common/types";
 
 const log = Logger.getLogger(__filename);
 
-type ExtensionPackageJson = { name: string; version: string; main: string; publisher?: string };
-
 /**
  * Returns a unique identifier for an extension based on the publisher and package name. The
  * publisher can either be explicitly specified with a "publisher" field or extracted from the
@@ -27,7 +26,7 @@ type ExtensionPackageJson = { name: string; version: string; main: string; publi
  * @param pkgJson Parsed package.json file
  * @returns An identifier string such as "lichtblick.suite-extension-turtlesim"
  */
-function getPackageId(pkgJson: undefined | ExtensionPackageJson): string {
+export function getPackageId(pkgJson: undefined | ExtensionPackageJson): string {
   if (pkgJson == undefined) {
     throw new Error(`Missing package.json`);
   }
@@ -58,7 +57,7 @@ function getPackageId(pkgJson: undefined | ExtensionPackageJson): string {
  * @param pkgJson Parsed package.json file
  * @returns A directory name such as "lichtblick.suite-extension-turtlesim-1.0.0"
  */
-function getPackageDirname(pkgJson: ExtensionPackageJson): string {
+export function getPackageDirname(pkgJson: ExtensionPackageJson): string {
   const pkgId = getPackageId(pkgJson);
   const dir = `${pkgId}-${pkgJson.version}`;
   if (dir.length >= 255) {
@@ -72,7 +71,7 @@ function getPackageDirname(pkgJson: ExtensionPackageJson): string {
  * @param name The "name" field from a package.json file
  * @returns An object containing the unprefixed name and the namespace, if present
  */
-function parsePackageName(name: string): { namespace?: string; name: string } {
+export function parsePackageName(name: string): { namespace?: string; name: string } {
   const res = /^@([^/]+)\/(.+)/.exec(name);
   if (res == undefined) {
     return { name };
@@ -80,6 +79,14 @@ function parsePackageName(name: string): { namespace?: string; name: string } {
   return { namespace: res[1], name: res[2]! };
 }
 
+const safeReadFile = async (filePath: string): Promise<string> => {
+  try {
+    return await readFile(filePath, { encoding: "utf8" });
+  } catch {
+    return "";
+  }
+};
+
 export async function getExtensions(rootFolder: string): Promise<DesktopExtension[]> {
   const extensions: DesktopExtension[] = [];
 
@@ -98,10 +105,16 @@ export async function getExtensions(rootFolder: string): Promise<DesktopExtensio
       const packagePath = pathJoin(extensionRootPath, "package.json");
       const packageData = await readFile(packagePath, { encoding: "utf8" });
       const packageJson = JSON.parse(packageData) as ExtensionPackageJson;
+      const readmePath = pathJoin(extensionRootPath, "README.md");
+      const changelogPath = pathJoin(extensionRootPath, "CHANGELOG.md");
+      const [readme, changelog] = await Promise.all([
+        safeReadFile(readmePath),
+        safeReadFile(changelogPath),
+      ]);
 
       const id = getPackageId(packageJson);
 
-      extensions.push({ id, packageJson, directory: extensionRootPath });
+      extensions.push({ id, packageJson, directory: extensionRootPath, readme, changelog });
     } catch (err: unknown) {
       log.error(err);
     }
@@ -150,6 +163,11 @@ export async function installExtension(
     throw new Error(`Extension contains an invalid package.json`);
   }
 
+  const readmeZipObj = archive.files["README.md"];
+  const changelogZipObj = archive.files["CHANGELOG.md"];
+  const readme = readmeZipObj ? await readmeZipObj.async("string") : "";
+  const changelog = changelogZipObj ? await changelogZipObj.async("string") : "";
+
   // Check for basic validity of package.json and get the packageId
   const packageId = getPackageId(pkgJson);
 
@@ -177,6 +195,8 @@ export async function installExtension(
     id: packageId,
     packageJson: pkgJson,
     directory: extensionBaseDir,
+    readme,
+    changelog,
   };
 }
 
diff --git a/packages/suite-desktop/src/preload/types.ts b/packages/suite-desktop/src/preload/types.ts
new file mode 100644
index 0000000000..21e52c7eb0
--- /dev/null
+++ b/packages/suite-desktop/src/preload/types.ts
@@ -0,0 +1,9 @@
+// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<lichtblick@bmwgroup.com>
+// SPDX-License-Identifier: MPL-2.0
+
+export type ExtensionPackageJson = {
+  name: string;
+  version: string;
+  main: string;
+  publisher?: string;
+};
diff --git a/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.test.ts b/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.test.ts
index 0dae77ee8c..d527e2f4a2 100644
--- a/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.test.ts
+++ b/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.test.ts
@@ -38,12 +38,14 @@ describe("DesktopExtensionLoader", () => {
       const extension: DesktopExtension = {
         id: genericString(),
         packageJson: { displayName },
-      } as DesktopExtension;
+        readme: genericString(),
+        changelog: genericString(),
+        directory: genericString(),
+      };
 
       mockBridge.getExtensions.mockResolvedValueOnce([extension]);
 
       const result = await loader.getExtension(extension.id);
-
       expect(mockBridge.getExtensions).toHaveBeenCalledTimes(1);
       expect(result).toEqual({
         id: extension.id,
@@ -51,7 +53,9 @@ describe("DesktopExtensionLoader", () => {
         namespace: desktopExtensionLoader.namespace,
         qualifiedName: displayName,
         displayName,
-      } as ExtensionInfo);
+        readme: extension.readme,
+        changelog: extension.changelog,
+      });
     });
 
     it("should return undefined if the extension does not exist", async () => {
@@ -72,10 +76,14 @@ describe("DesktopExtensionLoader", () => {
         {
           id: genericString(),
           packageJson: { displayName: genericString() } as ExtensionInfo,
+          readme: genericString(),
+          changelog: genericString(),
         } as DesktopExtension,
         {
           id: genericString(),
           packageJson: { displayName: genericString() } as ExtensionInfo,
+          readme: genericString(),
+          changelog: genericString(),
         } as DesktopExtension,
       ];
       mockBridge.getExtensions.mockResolvedValue(extensions);
@@ -90,6 +98,8 @@ describe("DesktopExtensionLoader", () => {
           name: (extensions[0]?.packageJson as ExtensionInfo).displayName,
           namespace: desktopExtensionLoader.namespace,
           qualifiedName: (extensions[0]?.packageJson as ExtensionInfo).displayName,
+          readme: extensions[0]?.readme,
+          changelog: extensions[0]?.changelog,
         },
         {
           ...(extensions[1]?.packageJson as ExtensionInfo),
@@ -97,6 +107,8 @@ describe("DesktopExtensionLoader", () => {
           name: (extensions[1]?.packageJson as ExtensionInfo).displayName,
           namespace: desktopExtensionLoader.namespace,
           qualifiedName: (extensions[1]?.packageJson as ExtensionInfo).displayName,
+          readme: extensions[1]?.readme,
+          changelog: extensions[1]?.changelog,
         },
       ]);
     });
@@ -141,6 +153,8 @@ describe("DesktopExtensionLoader", () => {
       const extension: DesktopExtension = {
         id: genericString(),
         packageJson: { displayName } as ExtensionInfo,
+        readme: genericString(),
+        changelog: genericString(),
       } as DesktopExtension;
 
       mockBridge.installExtension.mockResolvedValue(extension);
@@ -154,6 +168,8 @@ describe("DesktopExtensionLoader", () => {
         name: displayName,
         namespace: desktopExtensionLoader.namespace,
         qualifiedName: displayName,
+        readme: extension.readme,
+        changelog: extension.changelog,
       });
     });
 
diff --git a/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.ts b/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.ts
index 4baef6c729..c759907864 100644
--- a/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.ts
+++ b/packages/suite-desktop/src/renderer/services/DesktopExtensionLoader.ts
@@ -38,6 +38,8 @@ export class DesktopExtensionLoader implements ExtensionLoader {
         namespace: this.namespace,
         // Qualified name is display name for backwards compatibility with existing layouts.
         qualifiedName: pkgInfo.displayName,
+        readme: extension.readme,
+        changelog: extension.changelog,
       };
     });
 
@@ -63,6 +65,8 @@ export class DesktopExtensionLoader implements ExtensionLoader {
       namespace: this.namespace,
       // Qualified name is display name for backwards compatibility with existing layouts.
       qualifiedName: pkgInfo.displayName,
+      readme: extension.readme,
+      changelog: extension.changelog,
     };
   }
 
diff --git a/yarn.lock b/yarn.lock
index 1d5b3408aa..873cfd145a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3017,6 +3017,7 @@ __metadata:
     css-loader: 6.8.1
     cytoscape: 3.31.1
     cytoscape-dagre: 2.5.0
+    dompurify: 3.2.5
     esbuild-loader: 2.21.0
     eventemitter3: 5.0.1
     fake-indexeddb: 4.0.2
@@ -5724,6 +5725,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/trusted-types@npm:^2.0.7":
+  version: 2.0.7
+  resolution: "@types/trusted-types@npm:2.0.7"
+  checksum: 8e4202766a65877efcf5d5a41b7dd458480b36195e580a3b1085ad21e948bc417d55d6f8af1fd2a7ad008015d4117d5fdfe432731157da3c68678487174e4ba3
+  languageName: node
+  linkType: hard
+
 "@types/uglify-js@npm:*":
   version: 3.17.5
   resolution: "@types/uglify-js@npm:3.17.5"
@@ -9439,6 +9447,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"dompurify@npm:3.2.5":
+  version: 3.2.5
+  resolution: "dompurify@npm:3.2.5"
+  dependencies:
+    "@types/trusted-types": ^2.0.7
+  dependenciesMeta:
+    "@types/trusted-types":
+      optional: true
+  checksum: bd3b40810cc74312970dc6b99d38450ec839c81fb56c27d2c1b815ce0206b73e9cd4fe2e9021f735a1aaebf017990a63e6454e7d9c893f72b365b94d030ec9c4
+  languageName: node
+  linkType: hard
+
 "domutils@npm:^2.5.2, domutils@npm:^2.8.0":
   version: 2.8.0
   resolution: "domutils@npm:2.8.0"


Reviews:
user: login: luluiz
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-08T14:44:09Z
user: login: gabriela-almeida-ctw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T15:32:35Z
user: login: gabriela-almeida-ctw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T16:35:36Z
user: login: ctw-joao-luis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T08:26:00Z
user: login: ctw-joao-luis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T08:31:02Z
user: login: ctw-joao-luis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T09:27:24Z
user: login: luluiz
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-09T12:40:45Z
user: login: gabriela-almeida-ctw
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T14:15:28Z

Review comments:
diff_hunk: @@ -188,5 +188,8 @@
     "vm-browserify": "1.1.2",
     "webpack": "5.99.7",
     "zustand": "4.4.1"
+  },
+  "dependencies": {
+    "dompurify": "3.2.5"
path: packages/suite-base/package.json
user: login: luluiz
body: I'd say to install it as dev dependency
created_at: 2025-05-08T14:35:11Z
author_association: MEMBER
diff_hunk: @@ -24,4 +24,6 @@ export type ExtensionInfo = {
   publisher: string;
   qualifiedName: string;
   version: string;
+  readme?: string;
+  changelog?: string;
path: packages/suite-base/src/types/Extensions.ts
user: login: gabriela-almeida-ctw
body: Why did you choose to make these attributes optional? Extensions generated using [create-lichtblick-extension](https://github.com/lichtblick-suite/create-lichtblick-extension) include both a README and a changelog by default. Also, these attributes are mandatory for the DesktopExtension type. Shouldn’t we aim for consistency?
created_at: 2025-05-08T15:32:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -98,10 +97,20 @@ export async function getExtensions(rootFolder: string): Promise<DesktopExtensio
       const packagePath = pathJoin(extensionRootPath, "package.json");
       const packageData = await readFile(packagePath, { encoding: "utf8" });
       const packageJson = JSON.parse(packageData) as ExtensionPackageJson;
+      const readmePath = pathJoin(extensionRootPath, "README.md");
+      const readme =
path: packages/suite-desktop/src/preload/extensions.ts
user: login: gabriela-almeida-ctw
body: Since README.md and CHANGELOG.md are optional, this code can be simplified and made more robust:
- Avoid redundancy: readFile is currently called twice if the file exists.
- Improve error handling: Wrapping the read in a try-catch ensures we gracefully handle missing or unreadable files.

Suggested refactor:

```suggestion
const safeReadFile = async (filePath: string): Promise<string> => {
  try {
    return await readFile(filePath, { encoding: "utf8" });
  } catch {
    return "";
  }
};
const [readme, changelog] = await Promise.all([
  safeReadFile(readmePath),
  safeReadFile(changelogPath),
]);
```
created_at: 2025-05-08T16:33:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -188,5 +188,8 @@
     "vm-browserify": "1.1.2",
     "webpack": "5.99.7",
     "zustand": "4.4.1"
+  },
+  "dependencies": {
+    "dompurify": "3.2.5"
path: packages/suite-base/package.json
user: login: ctw-joao-luis
body: Done
created_at: 2025-05-09T08:26:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,4 +24,6 @@ export type ExtensionInfo = {
   publisher: string;
   qualifiedName: string;
   version: string;
+  readme?: string;
+  changelog?: string;
path: packages/suite-base/src/types/Extensions.ts
user: login: ctw-joao-luis
body: The idea is that there could be Extensions out there without `readme` or `changelog` and the only files that are a must for Lichtblick to load an extension are `package.json` and the `dist` folder therefore this is to avoid Lichtblick not being able to load an extension because of files that aren't actually a must.
Also even if there's no `readme` or `changelog` the logic that I implemented guarantees, in those cases, an empty string as value for those attributes.
created_at: 2025-05-09T08:31:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -98,10 +97,20 @@ export async function getExtensions(rootFolder: string): Promise<DesktopExtensio
       const packagePath = pathJoin(extensionRootPath, "package.json");
       const packageData = await readFile(packagePath, { encoding: "utf8" });
       const packageJson = JSON.parse(packageData) as ExtensionPackageJson;
+      const readmePath = pathJoin(extensionRootPath, "README.md");
+      const readme =
path: packages/suite-desktop/src/preload/extensions.ts
user: login: ctw-joao-luis
body: Suggestion implemented!
created_at: 2025-05-09T09:27:24Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-09T09:30:01Z
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=495) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=Lichtblick-Suite_lichtblick&pullRequest=495&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=Lichtblick-Suite_lichtblick&pullRequest=495&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=495&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [84.3% Coverage on New Code](https://sonarcloud.io/component_measures?id=Lichtblick-Suite_lichtblick&pullRequest=495&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.7% Duplication on New Code](https://sonarcloud.io/component_measures?id=Lichtblick-Suite_lichtblick&pullRequest=495&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=Lichtblick-Suite_lichtblick&pullRequest=495)


author_association: NONE