podman-desktop/packages/main/src/plugin/webview/webview-registry.ts
Vladyslav Zhukovskyi 68e6a2436c feat: implement DevTools lifecycle management to prevent app crashes
Signed-off-by: Vladyslav Zhukovskyi <vzhukovs@redhat.com>
2025-10-03 15:20:41 +03:00

350 lines
10 KiB
TypeScript

/**********************************************************************
* Copyright (C) 2024 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 { existsSync } from 'node:fs';
import type * as http from 'node:http';
import { resolve } from 'node:path';
import type * as podmanDesktopAPI from '@podman-desktop/api';
import type { Application } from 'express';
import express from 'express';
import { inject, injectable } from 'inversify';
import { ApiSenderType } from '/@/plugin/api.js';
import { Uri } from '/@/plugin/types/uri.js';
import type { WebviewInfo, WebviewSimpleInfo } from '/@api/webview-info.js';
import { getFreePort } from '../util/port.js';
import { DevToolsManager } from './devtools-manager.js';
import { WebviewImpl } from './webview-impl.js';
import { WebviewPanelImpl } from './webview-panel-impl.js';
type IconPath = Uri | { readonly light: Uri; readonly dark: Uri };
export class HttpServer {
#app: Application;
#instance: http.Server | undefined;
constructor(app: Application) {
this.#app = app;
this.config(app);
}
private config(_app: Application): void {}
async start(serverPort: number): Promise<void> {
console.log('Starting http server to handle webviews on port', serverPort);
// now listen on the port
await new Promise<void>((resolve, reject) => {
this.#instance = this.#app
.listen(serverPort, () => {
resolve();
})
.on('error', (err: unknown) => {
reject(new Error(String(err)));
});
});
}
async stop(): Promise<void> {
if (!this.#instance) {
return;
}
return new Promise<void>((resolve, reject) => {
this.#instance?.close((err: unknown) => {
if (err) {
reject(new Error(String(err)));
} else {
resolve();
}
});
});
}
}
@injectable()
export class WebviewRegistry {
#count = 0;
#webviews: Map<string, WebviewPanelImpl>;
#apiSender: ApiSenderType;
// express server instance for serving webviews
#expressServer: HttpServer;
#uuidAndPaths: Map<string, string>;
#serverPort: number = 0;
#app: Application;
constructor(
@inject(ApiSenderType) apiSender: ApiSenderType,
@inject(DevToolsManager) private devToolsManager: DevToolsManager,
) {
this.#apiSender = apiSender;
this.#webviews = new Map();
this.#uuidAndPaths = new Map();
this.#app = express();
this.#expressServer = new HttpServer(this.#app);
}
protected initRouting(): void {
const router = this.buildRouter();
this.configureRouter(router);
this.useAppRouter(router);
}
protected useAppRouter(router: express.Router): void {
this.#app.use('/', router);
}
protected configureRouter(router: express.Router): void {
router.get('/*', (req, res) => {
// no referrer, reject request
if (!req.headers.referer) {
res.status(500).send('invalid request');
return;
}
// return empty page in case of root path access
if (req.path === '/') {
res.send('<html></html>');
return;
}
// get uuid of the request using the host
// extract 58b7cdef-294f-4ace-93cc-de6e0a139592 from req.hostname 58b7cdef-294f-4ace-93cc-de6e0a139592.webview.localhost
// check if hostname matches uuid pattern
const uuidPattern = new RegExp('^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$');
const uuid = req.hostname.split('.')[0];
if (!uuid) {
res.status(500).send('invalid request: uuid');
return;
}
if (!uuidPattern.test(uuid)) {
res.status(404).send('not found');
return;
}
// check if webview with uuid exists
const rootExtensionPath = this.#uuidAndPaths.get(uuid);
if (!rootExtensionPath) {
// throw an error (500)
res.status(500).send('missing parameter');
return;
}
// construct path from root path and req.path
const fullPath = `${rootExtensionPath}/${req.path}`;
// make it absolute
const absolutePath = resolve(fullPath);
// root path in absolute form
const rootExtensionPathAbsolute = resolve(rootExtensionPath);
// check that path is subfolder of root path
if (!absolutePath.startsWith(rootExtensionPathAbsolute)) {
res.status(404).send('not found');
return;
}
// check that path exists on the filesystem
if (!existsSync(absolutePath)) {
res.status(404).send('not found');
return;
}
// send content of absolute path with express
res.sendFile(absolutePath);
});
}
protected buildRouter(): express.Router {
return express.Router({
strict: true,
});
}
async start(): Promise<void> {
// init routing
this.initRouting();
// grab a free port
this.#serverPort = await getFreePort(44000);
// start the express server
await this.#expressServer.start(this.#serverPort);
}
async makeDefaultWebviewVisible(id: string): Promise<void> {
// filter all webviews matching the given id
const activeWebviewPanels = Array.from(this.#webviews.values()).filter(webviewPanel => {
return webviewPanel.internalId === id;
});
// update the state of the webviews
for (const webviewPanel of activeWebviewPanels) {
webviewPanel.updateViewState(true, true);
}
// now, update the state of the non active webviews
const nonActiveWebviewPanels = Array.from(this.#webviews.values()).filter(webviewPanel => {
return webviewPanel.internalId !== id;
});
for (const webviewPanel of nonActiveWebviewPanels) {
webviewPanel.updateViewState(false, false);
}
}
// stop the express server
async stop(): Promise<void> {
await this.#expressServer.stop();
}
createWebviewPanel(
extensionInfo: { id: string; extensionPath: string; icon?: string | { light: string; dark: string } },
viewType: string,
title: string,
options?: podmanDesktopAPI.WebviewOptions,
): podmanDesktopAPI.WebviewPanel {
const id = `${this.#count}`;
this.#count++;
// default icon is the extension icon
let iconPath: IconPath | undefined = undefined;
if (extensionInfo.icon) {
if (typeof extensionInfo.icon === 'string') {
iconPath = Uri.file(extensionInfo.icon);
} else {
iconPath = {
light: Uri.file(extensionInfo.icon.light),
dark: Uri.file(extensionInfo.icon.dark),
};
}
}
const webview: WebviewImpl = new WebviewImpl(
viewType,
id,
this.#apiSender,
extensionInfo,
this.#serverPort,
options ?? {},
);
const webviewPanelImpl = new WebviewPanelImpl(id, this, this.#apiSender, webview, {
title,
iconPath,
webviewOptions: options,
viewType,
});
this.#uuidAndPaths.set(webview.uuid, extensionInfo.extensionPath);
this.#webviews.set(webviewPanelImpl.internalId, webviewPanelImpl);
this.#apiSender.send('webview-create', webviewPanelImpl.internalId);
return webviewPanelImpl;
}
disposeWebviewPanel(webviewPanelImpl: WebviewPanelImpl): void {
this.#webviews.delete(webviewPanelImpl.internalId);
this.#apiSender.send('webview-delete', webviewPanelImpl.internalId);
}
async postMessageToWebview(id: string, message: { data: unknown }): Promise<void> {
/// grab the webview
const webviewPanelImpl = this.#webviews.get(id);
await webviewPanelImpl?.webview.handleMessage(message.data);
}
async updateWebviewState(id: string, state: unknown): Promise<void> {
/// grab the webview
const webviewPanelImpl = this.#webviews.get(id);
if (webviewPanelImpl) {
// update state
webviewPanelImpl.webview.state = state;
}
}
getRegistryHttpPort(): number {
return this.#serverPort;
}
/**
* Register DevTools for a WebView when they are opened.
* This method is called via IPC from the renderer process.
*/
async registerWebviewDevTools(webcontentId: number): Promise<void> {
return this.devToolsManager.registerDevTools(webcontentId);
}
/**
* Clean up DevTools when a WebView is being destroyed.
* This method is called via IPC from the renderer process.
*/
async cleanupWebviewDevTools(webcontentId: number): Promise<void> {
return this.devToolsManager.cleanupDevTools(webcontentId);
}
listWebviews(): WebviewInfo[] {
return Array.from(this.#webviews.entries()).map(entry => {
const id = entry[0];
const webviewPanelImpl = entry[1];
const viewType: string = webviewPanelImpl.viewType;
const sourcePath = webviewPanelImpl.webview.extensionInfo.extensionPath;
const html = webviewPanelImpl.webview.html;
const state = webviewPanelImpl.webview.state;
let icon;
if (webviewPanelImpl.iconPath instanceof Uri) {
icon = webviewPanelImpl.iconPath.fsPath;
}
return {
id,
viewType,
sourcePath,
html,
icon,
name: webviewPanelImpl.title,
uuid: webviewPanelImpl.webview.uuid,
state,
};
});
}
// for external usage, do not expose path/html/internal details
async listSimpleWebviews(): Promise<WebviewSimpleInfo[]> {
return Array.from(this.#webviews.entries()).map(entry => {
const id = entry[0];
const webviewPanelImpl = entry[1];
const viewType: string = webviewPanelImpl.viewType;
return {
id,
viewType,
title: webviewPanelImpl.title,
};
});
}
}