diff --git a/packages/main/src/plugin/api/font-info.ts b/packages/main/src/plugin/api/font-info.ts new file mode 100644 index 000000000000..5317038742d8 --- /dev/null +++ b/packages/main/src/plugin/api/font-info.ts @@ -0,0 +1,32 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface FontSource { + readonly location: string; + readonly format: string; +} + +export interface FontDefinition { + readonly fontId: string; + readonly src: FontSource[]; +} + +export interface FontInfo { + id: string; + definition: FontDefinition; +} diff --git a/packages/main/src/plugin/api/icon-info.ts b/packages/main/src/plugin/api/icon-info.ts new file mode 100644 index 000000000000..4ffcc52dc861 --- /dev/null +++ b/packages/main/src/plugin/api/icon-info.ts @@ -0,0 +1,30 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { FontDefinition } from './font-info.js'; + +export interface IconDefinition { + description?: string; + font?: FontDefinition; + fontCharacter: string; +} + +export interface IconInfo { + id: string; + definition: IconDefinition; +} diff --git a/packages/main/src/plugin/authentication.spec.ts b/packages/main/src/plugin/authentication.spec.ts index c100bb77cd08..dea8b1b8fefd 100644 --- a/packages/main/src/plugin/authentication.spec.ts +++ b/packages/main/src/plugin/authentication.spec.ts @@ -44,6 +44,7 @@ import type { StatusBarRegistry } from './statusbar/statusbar-registry.js'; import type { Telemetry } from './telemetry/telemetry.js'; import type { TrayMenuRegistry } from './tray-menu-registry.js'; import type { Proxy } from './proxy.js'; +import type { IconRegistry } from './icon-registry.js'; function randomNumber(n = 5) { return Math.round(Math.random() * 10 * n); @@ -243,6 +244,7 @@ suite('Authentication', () => { vi.fn() as unknown as ContainerProviderRegistry, vi.fn() as unknown as InputQuickPickRegistry, authentication, + vi.fn() as unknown as IconRegistry, vi.fn() as unknown as Telemetry, ); providerMock = { diff --git a/packages/main/src/plugin/extension-loader.spec.ts b/packages/main/src/plugin/extension-loader.spec.ts index ed7dbb788211..ee0b3fe332ab 100644 --- a/packages/main/src/plugin/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension-loader.spec.ts @@ -42,6 +42,7 @@ import type { AuthenticationImpl } from './authentication.js'; import type { MessageBox } from './message-box.js'; import type { Telemetry } from './telemetry/telemetry.js'; import type * as containerDesktopAPI from '@podman-desktop/api'; +import type { IconRegistry } from './icon-registry.js'; class TestExtensionLoader extends ExtensionLoader { public async setupScanningDirectory(): Promise { @@ -97,6 +98,8 @@ const inputQuickPickRegistry: InputQuickPickRegistry = {} as unknown as InputQui const authenticationProviderRegistry: AuthenticationImpl = {} as unknown as AuthenticationImpl; +const iconRegistry: IconRegistry = {} as unknown as IconRegistry; + const telemetryTrackMock = vi.fn(); const telemetry: Telemetry = { track: telemetryTrackMock } as unknown as Telemetry; @@ -120,6 +123,7 @@ beforeAll(() => { containerProviderRegistry, inputQuickPickRegistry, authenticationProviderRegistry, + iconRegistry, telemetry, ); }); diff --git a/packages/main/src/plugin/extension-loader.ts b/packages/main/src/plugin/extension-loader.ts index 435f183ee1dd..f2730e711bfa 100644 --- a/packages/main/src/plugin/extension-loader.ts +++ b/packages/main/src/plugin/extension-loader.ts @@ -56,6 +56,7 @@ import type { Telemetry } from './telemetry/telemetry.js'; import { TelemetryTrustedValue } from './types/telemetry.js'; import { clipboard as electronClipboard } from 'electron'; import { securityRestrictionCurrentHandler } from '../security-restrictions-handler.js'; +import type { IconRegistry } from './icon-registry.js'; /** * Handle the loading of an extension @@ -128,6 +129,7 @@ export class ExtensionLoader { private containerProviderRegistry: ContainerProviderRegistry, private inputQuickPickRegistry: InputQuickPickRegistry, private authenticationProviderRegistry: AuthenticationImpl, + private iconRegistry: IconRegistry, private telemetry: Telemetry, ) {} @@ -516,6 +518,11 @@ export class ExtensionLoader { this.menuRegistry.registerMenus(menus); } + const icons = extension.manifest?.contributes?.icons; + if (icons) { + this.iconRegistry.registerIconContribution(extension, icons); + } + this.analyzedExtensions.set(extension.id, extension); this.extensionState.delete(extension.id); this.extensionStateErrors.delete(extension.id); diff --git a/packages/main/src/plugin/icon-registry.spec.ts b/packages/main/src/plugin/icon-registry.spec.ts new file mode 100644 index 000000000000..692f45399532 --- /dev/null +++ b/packages/main/src/plugin/icon-registry.spec.ts @@ -0,0 +1,81 @@ +/********************************************************************** + * Copyright (C) 2022 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeAll, beforeEach, expect, test, vi } from 'vitest'; +import type { ApiSenderType } from './api.js'; + +import { IconRegistry } from './icon-registry.js'; +import type { AnalyzedExtension } from './extension-loader.js'; + +let iconRegistry: IconRegistry; +const apiSenderSendMock = vi.fn(); + +beforeAll(async () => { + iconRegistry = new IconRegistry({ + send: apiSenderSendMock, + } as unknown as ApiSenderType); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('should register icon contribution', async () => { + const fontPath = 'bootstrap-icons.woff2'; + const fontCharacter = '\\F844'; + const iconDescription = 'This is my icon'; + const icons = { + 'my-icon-id': { + description: iconDescription, + default: { + fontPath, + fontCharacter, + }, + }, + }; + + const extensionPath = '/root/path'; + const extensionId = 'myextension.id'; + const extension = { + path: extensionPath, + id: extensionId, + } as AnalyzedExtension; + + // register icons + iconRegistry.registerIconContribution(extension, icons); + + // expect to have registered the icon + expect(apiSenderSendMock).toHaveBeenCalledWith('icon-update'); + expect(apiSenderSendMock).toHaveBeenCalledWith('font-update'); + + // grab the icons + const allIcons = iconRegistry.listIcons(); + expect(allIcons).toHaveLength(1); + const icon = allIcons[0]; + expect(icon.id).toBe('my-icon-id'); + expect(icon.definition.fontCharacter).toBe(fontCharacter); + expect(icon.definition.description).toBe(iconDescription); + expect(icon.definition.font).toBeDefined(); + expect(icon.definition.font?.src).toStrictEqual([ + { + format: 'woff2', + location: `${extensionPath}/${fontPath}`, + }, + ]); + expect(icon.definition.font?.fontId).toBe(`${extensionId}-${fontPath}`); +}); diff --git a/packages/main/src/plugin/icon-registry.ts b/packages/main/src/plugin/icon-registry.ts new file mode 100644 index 000000000000..cf7de2345fa8 --- /dev/null +++ b/packages/main/src/plugin/icon-registry.ts @@ -0,0 +1,122 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { ApiSenderType } from './api.js'; +import type { FontDefinition } from './api/font-info.js'; +import type { IconDefinition, IconInfo } from './api/icon-info.js'; +import type { AnalyzedExtension } from './extension-loader.js'; +import { join } from 'node:path'; + +export class IconRegistry { + private icons: Map; + private fonts: Map; + + constructor(private apiSender: ApiSenderType) { + this.icons = new Map(); + this.fonts = new Map(); + } + + protected registerIcon(iconId: string, definition: IconDefinition): void { + if (this.icons.has(iconId)) { + console.warn(`Icon ${iconId} already registered.`); + return; + } + this.icons.set(iconId, definition); + this.apiSender.send('icon-update'); + } + + protected registerFont(fontId: string, definition: FontDefinition): void { + if (this.fonts.has(fontId)) { + console.warn(`Font ${fontId} already registered.`); + return; + } + this.fonts.set(fontId, definition); + this.apiSender.send('font-update'); + } + + public registerIconContribution( + extension: AnalyzedExtension, + icons: { [key: string]: { description?: string; default?: { fontPath?: string; fontCharacter: string } } }, + ): void { + // register each font and icon + Object.entries(icons).forEach(([iconId, iconContribution]) => { + // is there any default icon? + const defaultAttributes = iconContribution.default; + if (!defaultAttributes) { + console.warn(`Expected contributes.icons.default for icon id ${iconId} to be defined.`); + return; + } + // check we have a font character + if (!defaultAttributes.fontCharacter) { + console.warn(`Expected contributes.icons.default.fontCharacter for icon id ${iconId} to be defined.`); + return; + } + + // font Path ? + if (!defaultAttributes.fontPath) { + console.warn(`Expected contributes.icons.default.fontPath for icon id ${iconId} to be defined.`); + return; + } + + // get file extension of the font path + const format = defaultAttributes.fontPath.split('.').pop() || ''; + if (format !== 'woff2') { + console.warn( + `Expected contributes.icons.default.fontPath to have file extension 'woff2' but found '${format}'."`, + ); + return; + } + + const iconFontLocation = join(extension.path, defaultAttributes.fontPath); + + // check that this location is inside the extension folder + if (!iconFontLocation.startsWith(extension.path)) { + console.warn( + `Expected contributes.icons.default.fontPath for icon id ${iconId} to be included inside extension's folder (${extension.path}).`, + ); + return; + } + + // fontId is based on the extension id and the font path + const fontId = `${extension.id}-${defaultAttributes.fontPath}`; + + // font definition + const fontDefinition: FontDefinition = { + fontId, + src: [{ location: iconFontLocation, format }], + }; + + // register font + this.registerFont(fontId, fontDefinition); + + // icon definition + const iconDefinition: IconDefinition = { + description: iconContribution.description, + fontCharacter: defaultAttributes.fontCharacter, + font: fontDefinition, + }; + + // and now register the icon + this.registerIcon(iconId, iconDefinition); + }); + } + + listIcons(): IconInfo[] { + return Array.from(this.icons.entries()).map(([id, definition]) => ({ id, definition })); + } +} diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index ea3532fdafab..78ed8ffc2a3b 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -106,6 +106,8 @@ import { ExtensionsCatalog } from './extensions-catalog/extensions-catalog.js'; import { securityRestrictionCurrentHandler } from '../security-restrictions-handler.js'; import { ExtensionsUpdater } from './extensions-updater/extensions-updater.js'; import type { CatalogExtension } from './extensions-catalog/extensions-catalog-api.js'; +import { IconRegistry } from './icon-registry.js'; +import type { IconInfo } from './api/icon-info.js'; type LogType = 'log' | 'warn' | 'trace' | 'debug' | 'error'; @@ -342,6 +344,8 @@ export class PluginSystem { // init api sender const apiSender = this.getApiSender(this.getWebContentsSender()); + const iconRegistry = new IconRegistry(apiSender); + const configurationRegistry = new ConfigurationRegistry(); configurationRegistry.init(); @@ -652,6 +656,7 @@ export class PluginSystem { containerProviderRegistry, inputQuickPickRegistry, authentication, + iconRegistry, telemetry, ); await this.extensionLoader.init(); @@ -1587,6 +1592,10 @@ export class PluginSystem { return app.getVersion(); }); + this.ipcHandle('iconRegistry:listIcons', async (): Promise => { + return iconRegistry.listIcons(); + }); + this.ipcHandle('window:minimize', async (): Promise => { const window = BrowserWindow.getAllWindows().find(w => !w.isDestroyed()); if (!window) { diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 4b581e8ca1c6..4bba1499f328 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -33,6 +33,7 @@ import type { ImageInspectInfo } from '../../main/src/plugin/api/image-inspect-i import type { HistoryInfo } from '../../main/src/plugin/api/history-info'; import type { ContainerInspectInfo } from '../../main/src/plugin/api/container-inspect-info'; import type { ContainerStatsInfo } from '../../main/src/plugin/api/container-stats-info'; +import type { IconInfo } from '../../main/src/plugin/api/icon-info'; import type { ExtensionInfo } from '../../main/src/plugin/api/extension-info'; import type { FeaturedExtension } from '../../main/src/plugin/featured/featured-api'; import type { CatalogExtension } from '../../main/src/plugin/extensions-catalog/extensions-catalog-api'; @@ -912,6 +913,10 @@ function initExposure(): void { return ipcInvoke('contributions:listContributions'); }); + contextBridge.exposeInMainWorld('listIcons', async (): Promise => { + return ipcInvoke('iconRegistry:listIcons'); + }); + // Handle callback to open devtools for extensions // by delegating to the renderer process ipcRenderer.on('dev-tools:open-extension', (_, extensionId: string) => { diff --git a/packages/renderer/src/App.svelte b/packages/renderer/src/App.svelte index 24ad7aa820cb..c3d50b188102 100644 --- a/packages/renderer/src/App.svelte +++ b/packages/renderer/src/App.svelte @@ -37,6 +37,7 @@ import TaskManager from './lib/task-manager/TaskManager.svelte'; import MessageBox from './lib/dialogs/MessageBox.svelte'; import TitleBar from './lib/ui/TitleBar.svelte'; import TroubleshootingPage from './lib/troubleshooting/TroubleshootingPage.svelte'; +import IconsStyle from './lib/style/IconsStyle.svelte'; router.mode.hash(); @@ -61,6 +62,7 @@ window.events?.receive('display-troubleshooting', () => {
+ diff --git a/packages/renderer/src/lib/statusbar/StatusBarItem.svelte b/packages/renderer/src/lib/statusbar/StatusBarItem.svelte index 3574133a0ee9..afd00e3b8a90 100644 --- a/packages/renderer/src/lib/statusbar/StatusBarItem.svelte +++ b/packages/renderer/src/lib/statusbar/StatusBarItem.svelte @@ -10,6 +10,15 @@ function iconClass(entry: StatusBarEntry): string | undefined { } else if (!entry.enabled && entry.inactiveIconClass !== undefined && entry.inactiveIconClass.trim().length !== 0) { iconClass = entry.inactiveIconClass; } + // handle ${} in icon class + // and interpret the value and replace with the class-name + if (iconClass !== undefined) { + const match = iconClass.match(/\$\{(.*)\}/); + if (match !== null && match.length === 2) { + const className = match[1]; + iconClass = iconClass.replace(match[0], `podman-desktop-icon-${className}`); + } + } return iconClass; } diff --git a/packages/renderer/src/lib/style/IconsStyle.spec.ts b/packages/renderer/src/lib/style/IconsStyle.spec.ts new file mode 100644 index 000000000000..d6a4f0be8dd7 --- /dev/null +++ b/packages/renderer/src/lib/style/IconsStyle.spec.ts @@ -0,0 +1,75 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import '@testing-library/jest-dom'; +import { beforeAll, test, expect, vi } from 'vitest'; +import { render } from '@testing-library/svelte'; +import IconsStyle from './IconsStyle.svelte'; +import { iconsInfos } from '/@/stores/icons'; +import { get } from 'svelte/store'; +import type { IconInfo } from '../../../../main/src/plugin/api/icon-info'; + +const listIconsMock = vi.fn(); + +// fake the window object +beforeAll(() => { + (window as any).listIcons = listIconsMock; +}); + +test('Check containers button is available and click on it', async () => { + const icon: IconInfo = { + id: 'my-icon-id', + definition: { + description: 'This is a description', + font: { + fontId: 'my-font-id', + src: [ + { + location: 'my-font.woff', + format: 'woff2', + }, + ], + }, + fontCharacter: '\\E01', + }, + }; + + listIconsMock.mockResolvedValue([icon]); + + window.dispatchEvent(new CustomEvent('extensions-already-started')); + + // wait store is populated + while (get(iconsInfos).length === 0) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + + render(IconsStyle); + + // expect to have the generated style for the icon + const style = document.querySelector('style'); + expect(style).toBeInTheDocument(); + // should have css type + expect(style).toHaveAttribute('type', 'text/css'); + + // check content + expect(style).toHaveTextContent( + `.podman-desktop-icon-my-icon-id:before { content: '\\E01'; font-family: '${icon.definition.font.fontId}'; } @font-face { src: url('file://my-font.woff') format('woff2'); font-family: 'my-font-id'; font-display: block; }`, + ); +}); diff --git a/packages/renderer/src/lib/style/IconsStyle.svelte b/packages/renderer/src/lib/style/IconsStyle.svelte new file mode 100644 index 000000000000..570f80df2340 --- /dev/null +++ b/packages/renderer/src/lib/style/IconsStyle.svelte @@ -0,0 +1,63 @@ + diff --git a/packages/renderer/src/stores/icons.ts b/packages/renderer/src/stores/icons.ts new file mode 100644 index 000000000000..23bdf9fa9076 --- /dev/null +++ b/packages/renderer/src/stores/icons.ts @@ -0,0 +1,56 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { Writable } from 'svelte/store'; +import { writable } from 'svelte/store'; +import type { IconInfo } from '../../../main/src/plugin/api/icon-info'; + +let readyToUpdate = false; + +export async function fetchIcons() { + // do not fetch until extensions are all started + if (!readyToUpdate) { + return; + } + + const result = await window.listIcons(); + iconsInfos.set(result); +} + +export const iconsInfos: Writable = writable([]); + +// need to refresh when extension is started or stopped +window.events?.receive('icon-update', async () => { + await fetchIcons(); +}); +window.events?.receive('extension-stopped', async () => { + await fetchIcons(); +}); + +window?.events?.receive('extensions-started', async () => { + readyToUpdate = true; + await fetchIcons(); +}); + +// if client is doing a refresh, we will receive this event and we need to update the data +window.addEventListener('extensions-already-started', () => { + readyToUpdate = true; + fetchIcons().catch((error: unknown) => { + console.error('Failed to fetch icons', error); + }); +});