chore(config): add setting to disable extension catalog (#16425)

### What does this PR do?

Adds a setting to disable the extension catalog throughout PD.

This can be done by adding the setting manually to your
`~/.local/share/containers/podman-desktop/configuration/settings.json`
with:

```
"extensions.catalog.enabled": false
```

Which will disable the appearance of the catalog throughout PD.

### Screenshot / video of UI

<!-- If this PR is changing UI, please include
screenshots or screencasts showing the difference -->

### What issues does this PR fix or reference?

<!-- Include any related issues from Podman Desktop
repository (or from another issue tracker). -->

Closes https://github.com/podman-desktop/podman-desktop/issues/16036

### How to test this PR?

<!-- Please explain steps to verify the functionality,
do not forget to provide unit/component tests -->

- [X] Tests are covering the bug fix or the new feature

1. Edit
   `~/.local/share/containers/podman-desktop/configuration/settings.json`
2. Add: `"extensions.catalog.enabled": false`
3. Start / Restart PD
4. See that you cannot access the catalog from the following places:
   Extensions, Settings > Authentication, and Kubernetes (with no
   context).

Signed-off-by: Charlie Drage <charlie@charliedrage.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Charlie Drage 2026-03-12 11:04:29 -04:00 committed by GitHub
parent 07831c04cc
commit b6a6aac90a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 116 additions and 25 deletions

View file

@ -19,4 +19,5 @@
export enum ExtensionsCatalogSettings {
SectionName = 'extensions',
registryUrl = 'registryUrl',
catalogEnabled = 'catalog.enabled',
}

View file

@ -400,7 +400,7 @@ test('Should use proxy object if proxySettings is undefined', () => {
expect(options.agent?.https?.proxy.href).toBe('https://localhost/');
});
test('should register local extensions enabled configuration property', () => {
test('should register local extensions and catalog enabled configuration properties', () => {
extensionsCatalog.init();
expect(configurationRegistry.registerConfigurations).toHaveBeenCalledWith([
@ -415,6 +415,12 @@ test('should register local extensions enabled configuration property', () => {
default: true,
hidden: true,
},
'extensions.catalog.enabled': {
description: 'Show the extension catalog in the UI. When disabled, hides the catalog suggestions.',
type: 'boolean',
default: true,
hidden: true,
},
}),
}),
]);

View file

@ -75,6 +75,12 @@ export class ExtensionsCatalog {
default: true,
hidden: true,
},
[ExtensionsCatalogSettings.SectionName + '.' + ExtensionsCatalogSettings.catalogEnabled]: {
description: 'Show the extension catalog in the UI. When disabled, hides the catalog suggestions.',
type: 'boolean',
default: true,
hidden: true,
},
},
};

View file

@ -34,6 +34,8 @@ beforeAll(() => {
beforeEach(() => {
vi.resetAllMocks();
// default to catalog enabled
vi.mocked(window.getConfigurationValue).mockResolvedValue(true);
});
const aFakeExtension: CatalogExtension = {
@ -218,3 +220,29 @@ test('empty catalog, hide if empty', async () => {
const emptyMsg = screen.queryByText('No extensions in the catalog');
expect(emptyMsg).not.toBeInTheDocument();
});
test('render nothing when catalog is disabled', async () => {
vi.mocked(window.getConfigurationValue).mockResolvedValue(false);
catalogExtensionInfos.set([aFakeExtension, bFakeExtension]);
extensionInfos.set(combined);
render(EmbeddableCatalogExtensionList, {});
await vi.waitFor(() => {
expect(screen.queryByText('Available extensions')).not.toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'A Extension' })).not.toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'B Extension' })).not.toBeInTheDocument();
});
});
test('render extensions when catalog is enabled', async () => {
vi.mocked(window.getConfigurationValue).mockResolvedValue(true);
catalogExtensionInfos.set([aFakeExtension, bFakeExtension]);
extensionInfos.set(combined);
render(EmbeddableCatalogExtensionList, {});
await vi.waitFor(() => {
expect(screen.queryByText('Available extensions')).toBeInTheDocument();
});
});

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { derived, type Readable } from 'svelte/store';
import { combinedInstalledExtensions } from '/@/stores/all-installed-extensions';
@ -20,6 +21,13 @@ export let ondetails: (extensionId: string) => void = () => {};
// show installed extensions
export let showInstalled: boolean = true;
let enableCatalog = true;
onMount(async () => {
const value = await window.getConfigurationValue<boolean>('extensions.catalog.enabled');
enableCatalog = value ?? true;
});
const extensionsUtils = new ExtensionsUtils();
const catalogExtensions: Readable<CatalogExtensionInfoUI[]> = derived(
@ -53,6 +61,7 @@ const catalogExtensions: Readable<CatalogExtensionInfoUI[]> = derived(
);
</script>
{#if enableCatalog}
<div class="flex bg-[var(--pd-content-bg)] text-left">
<CatalogExtensionList
oninstall={oninstall}
@ -61,3 +70,4 @@ const catalogExtensions: Readable<CatalogExtensionInfoUI[]> = derived(
showEmptyScreen={showEmptyScreen}
catalogExtensions={$catalogExtensions} />
</div>
{/if}

View file

@ -239,8 +239,8 @@ test('Search catalog page searches also description', async () => {
test('Expect to see local extensions tab content', async () => {
vi.mocked(window.getConfigurationValue).mockImplementation(async (key: string) => {
// Return true for local extensions enabled, false for development mode to show empty screen
return key === 'extensions.localExtensions.enabled';
// Return true for local extensions and catalog enabled
return key === 'extensions.localExtensions.enabled' || key === 'extensions.catalog.enabled';
});
catalogExtensionInfos.set([]);
extensionInfos.set([]);
@ -330,3 +330,28 @@ test('Expect local extensions tab to not be visible if extensions.localExtension
const localExtensionsTab = screen.queryByRole('button', { name: 'Local Extensions' });
expect(localExtensionsTab).not.toBeInTheDocument();
});
test('Expect catalog tab is visible', async () => {
vi.mocked(window.getConfigurationValue).mockResolvedValue(undefined);
catalogExtensionInfos.set([]);
extensionInfos.set([]);
render(ExtensionList);
await vi.waitFor(() => {
const catalogTab = screen.getByRole('button', { name: 'Catalog' });
expect(catalogTab).toBeInTheDocument();
});
});
test('Expect catalog tab to not be visible if extensions.catalog.enabled is false', async () => {
vi.mocked(window.getConfigurationValue).mockResolvedValue(false);
catalogExtensionInfos.set([]);
extensionInfos.set([]);
render(ExtensionList);
await vi.waitFor(() => {
expect(screen.queryByRole('button', { name: 'Catalog' })).not.toBeInTheDocument();
});
});

View file

@ -32,6 +32,8 @@ let enableLocalExtensions = $derived(
(await window.getConfigurationValue('extensions.localExtensions.enabled')) ?? true,
);
let enableCatalog = $derived((await window.getConfigurationValue('extensions.catalog.enabled')) ?? true);
const filteredInstalledExtensions: CombinedExtensionInfoUI[] = $derived(
extensionsUtils.filterInstalledExtensions($combinedInstalledExtensions, searchTerm),
);
@ -102,12 +104,14 @@ function changeScreen(newScreen: 'installed' | 'catalog' | 'development'): void
changeScreen('installed');
}}
selected={screen === 'installed'}>Installed</Button>
<Button
type="tab"
on:click={(): void => {
changeScreen('catalog');
}}
selected={screen === 'catalog'}>Catalog</Button>
{#if enableCatalog}
<Button
type="tab"
on:click={(): void => {
changeScreen('catalog');
}}
selected={screen === 'catalog'}>Catalog</Button>
{/if}
{#if enableLocalExtensions}
<Button
type="tab"
@ -129,7 +133,7 @@ function changeScreen(newScreen: 'installed' | 'catalog' | 'development'): void
on:resetFilter={(): string => (searchTerm = '')} />
{/if}
<InstalledExtensionList extensionInfos={filteredInstalledExtensions} />
{:else if screen === 'catalog'}
{:else if screen === 'catalog' && enableCatalog}
{#if searchTerm && filteredCatalogExtensions.length === 0}
<FilteredEmptyScreen
icon={ExtensionIcon}

View file

@ -22,7 +22,7 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { afterEach, beforeAll, expect, test, vi } from 'vitest';
import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest';
import { AppearanceUtil } from '/@/lib/appearance/appearance-util';
import { authenticationProviders } from '/@/stores/authenticationProviders';
@ -44,17 +44,22 @@ beforeAll(() => {
(window as any).getConfigurationValue = configMock;
});
beforeEach(() => {
// ensure we mock the config to not block rendering of the component (individual tests can override)
configMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
test('Expect that page shows icon and message when no auth providers registered', () => {
test('Expect that page shows icon and message when no auth providers registered', async () => {
render(PreferencesAuthenticationProvidersRendering, {});
const noProvidersText = screen.getByText('No authentication providers');
const noProvidersText = await waitFor(() => screen.getByText('No authentication providers'));
expect(noProvidersText).toBeInTheDocument();
});
test('Expect that page shows registered authentication providers without accounts as logged out', () => {
test('Expect that page shows registered authentication providers without accounts as logged out', async () => {
authenticationProviders.set([
{
id: 'test',
@ -63,7 +68,7 @@ test('Expect that page shows registered authentication providers without account
},
]);
render(PreferencesAuthenticationProvidersRendering, {});
const listOfProviders = screen.getByRole('list');
const listOfProviders = await waitFor(() => screen.getByRole('list'));
expect(listOfProviders).toBeInTheDocument();
const providerItem = screen.getByRole('listitem', { name: 'Test Authentication Provider' });
expect(providerItem).toBeInTheDocument();
@ -89,10 +94,10 @@ const testProvidersInfo = [
},
];
test('Expect that page shows registered authentication providers with account as logged in', () => {
test('Expect that page shows registered authentication providers with account as logged in', async () => {
authenticationProviders.set(testProvidersInfo);
render(PreferencesAuthenticationProvidersRendering, {});
const providerName = screen.getByLabelText('Provider Name');
const providerName = await waitFor(() => screen.getByLabelText('Provider Name'));
expect(providerName).toHaveTextContent('Test Authentication Provider');
const providerStatus = screen.getByLabelText('Provider Status');
expect(providerStatus).toBeInTheDocument();
@ -107,7 +112,9 @@ test('Expect that page shows registered authentication providers with account as
test('Expect Sign Out button click calls window.requestAuthenticationProviderSignOut with provider and account ids', async () => {
authenticationProviders.set(testProvidersInfo);
render(PreferencesAuthenticationProvidersRendering, {});
const signoutButton = screen.getByRole('button', { name: `Sign out of ${testProvidersInfo[0].accounts[0].label}` });
const signoutButton = await waitFor(() =>
screen.getByRole('button', { name: `Sign out of ${testProvidersInfo[0].accounts[0].label}` }),
);
const requestSignOutMock = vi.fn().mockImplementation(() => {});
(window as any).requestAuthenticationProviderSignOut = requestSignOutMock;
await fireEvent.click(signoutButton);
@ -126,8 +133,9 @@ const testProvidersInfoWithoutSessionRequests = [
test('Expect Sign in button to be hidden when there are no session requests', async () => {
authenticationProviders.set(testProvidersInfoWithoutSessionRequests);
render(PreferencesAuthenticationProvidersRendering, {});
const menuButton = screen.queryAllByRole('button', { name: 'Sign in' });
expect(menuButton.length).equals(0); // no menu button
await waitFor(() => {
expect(screen.queryAllByRole('button', { name: 'Sign in' }).length).equals(0);
});
});
const testProvidersInfoWithSessionRequests = [
@ -152,7 +160,7 @@ test('Expect Sign In button to be visible when there is only one session request
const requestSignInMock = vi.fn();
(window as any).requestAuthenticationProviderSignIn = requestSignInMock;
render(PreferencesAuthenticationProvidersRendering, {});
const menuButton = screen.getByRole('button', { name: 'Sign in' });
const menuButton = await waitFor(() => screen.getByRole('button', { name: 'Sign in' }));
const tooltipTrigger = screen.getByTestId('tooltip-trigger');
await fireEvent.mouseEnter(tooltipTrigger);
@ -191,7 +199,7 @@ test('Expect Sign In popup menu to be visible when there is more than one sessio
authenticationProviders.set(testProvidersInfoWithMultipleSessionRequests);
(window as any).requestAuthenticationProviderSignIn = vi.fn();
render(PreferencesAuthenticationProvidersRendering, {});
const menuButton = screen.getByRole('button', { name: 'kebab menu' });
const menuButton = await waitFor(() => screen.getByRole('button', { name: 'kebab menu' }));
await fireEvent.click(menuButton);
// test sign in with extension1
const menuItem1 = screen.getByText('Sign in to use Extension1 Label');
@ -210,9 +218,11 @@ test('Expect Sign In popup menu to be visible when there is more than one sessio
test('Expects default icon to be used when provider has no images option', async () => {
authenticationProviders.set(testProvidersInfoWithSessionRequests);
render(PreferencesAuthenticationProvidersRendering, {});
screen.getByRole('img', {
name: `Default icon for ${testProvidersInfoWithSessionRequests[0].displayName} provider`,
});
await waitFor(() =>
screen.getByRole('img', {
name: `Default icon for ${testProvidersInfoWithSessionRequests[0].displayName} provider`,
}),
);
});
test('Expects images.icon option to be used when no themes are present', async () => {

View file

@ -49,6 +49,7 @@ import TabItem from '@theme/TabItem';
| `editor.integrated.fontSize` | number | `10` | Editor font size (6-100 px) |
| `extensions.autoCheckUpdates` | boolean | `true` | Auto-check for extension updates |
| `extensions.autoUpdate` | boolean | `true` | Auto-install extension updates |
| `extensions.catalog.enabled` | boolean | `true` | Show the extension catalog in the UI |
| `extensions.customExtensions.enabled` | boolean | `true` | When disabled, the `Install custom...` button on the Extensions page will not appear. |
| `extensions.ignoreBannerRecommendations` | boolean | `false` | Disable recommendation banners |
| `extensions.ignoreRecommendations` | boolean | `false` | Disable extension recommendations |