import type {WebContents} from "electron/main"; import fs from "node:fs"; import process from "node:process"; import * as remote from "@electron/remote"; import {app, dialog} from "@electron/remote"; import * as ConfigUtil from "../../../common/config-util.js"; import type {Html} from "../../../common/html.js"; import {html} from "../../../common/html.js"; import type {RendererMessage} from "../../../common/typed-ipc.js"; import type {TabRole} from "../../../common/types.js"; import preloadCss from "../../css/preload.css?raw"; // eslint-disable-line n/file-extension-in-import import {ipcRenderer} from "../typed-ipc-renderer.js"; import * as SystemUtil from "../utils/system-util.js"; import {generateNodeFromHtml} from "./base.js"; import {contextMenu} from "./context-menu.js"; const shouldSilentWebview = ConfigUtil.getConfigItem("silent", false); type WebViewProps = { $root: Element; rootWebContents: WebContents; index: number; tabIndex: number; url: string; role: TabRole; isActive: () => boolean; switchLoading: (loading: boolean, url: string) => void; onNetworkError: (index: number) => void; preload?: string; onTitleChange: () => void; hasPermission?: (origin: string, permission: string) => boolean; }; export default class WebView { static templateHtml(props: WebViewProps): Html { return html` `; } static async create(props: WebViewProps): Promise { const $element = generateNodeFromHtml( WebView.templateHtml(props), ) as HTMLElement; props.$root.append($element); // Wait for did-navigate rather than did-attach to work around // https://github.com/electron/electron/issues/31918 await new Promise((resolve) => { $element.addEventListener( "did-navigate", () => { resolve(); }, true, ); }); // Work around https://github.com/electron/electron/issues/26904 function getWebContentsIdFunction( this: undefined, selector: string, ): number { return document .querySelector(selector)! .getWebContentsId(); } const selector = `webview[data-tab-id="${CSS.escape( `${props.tabIndex}`, )}"]`; const webContentsId: unknown = await props.rootWebContents.executeJavaScript( `(${getWebContentsIdFunction.toString()})(${JSON.stringify(selector)})`, ); if (typeof webContentsId !== "number") { throw new TypeError("Failed to get WebContents ID"); } return new WebView(props, $element, webContentsId); } zoomFactor: number; badgeCount: number; loading: boolean; customCss: string | false | null; $webviewsContainer: DOMTokenList; $el: HTMLElement; webContentsId: number; private constructor( readonly props: WebViewProps, $element: HTMLElement, webContentsId: number, ) { this.zoomFactor = 1; this.loading = true; this.badgeCount = 0; this.customCss = ConfigUtil.getConfigItem("customCSS", null); this.$webviewsContainer = document.querySelector( "#webviews-container", )!.classList; this.$el = $element; this.webContentsId = webContentsId; this.registerListeners(); } getWebContents(): WebContents { return remote.webContents.fromId(this.webContentsId); } registerListeners(): void { const webContents = this.getWebContents(); if (shouldSilentWebview) { webContents.setAudioMuted(true); } webContents.on("page-title-updated", (_event, title) => { this.badgeCount = this.getBadgeCount(title); this.props.onTitleChange(); }); this.$el.addEventListener("did-navigate-in-page", () => { this.canGoBackButton(); }); this.$el.addEventListener("did-navigate", () => { this.canGoBackButton(); }); webContents.on("page-favicon-updated", (_event, favicons) => { // This returns a string of favicons URL. If there is a PM counts in unread messages then the URL would be like // https://chat.zulip.org/static/images/favicon/favicon-pms.png if ( favicons[0].indexOf("favicon-pms") > 0 && process.platform === "darwin" ) { // This api is only supported on macOS app.dock.setBadge("●"); // Bounce the dock if (ConfigUtil.getConfigItem("dockBouncing", true)) { app.dock.bounce(); } } }); webContents.addListener("context-menu", (event, menuParameters) => { contextMenu(webContents, event, menuParameters); }); this.$el.addEventListener("dom-ready", () => { this.loading = false; this.props.switchLoading(false, this.props.url); this.show(); }); webContents.on("did-fail-load", (_event, _errorCode, errorDescription) => { const hasConnectivityError = SystemUtil.connectivityError.includes(errorDescription); if (hasConnectivityError) { console.error("error", errorDescription); if (!this.props.url.includes("network.html")) { this.props.onNetworkError(this.props.index); } } }); this.$el.addEventListener("did-start-loading", () => { this.props.switchLoading(true, this.props.url); }); this.$el.addEventListener("did-stop-loading", () => { this.props.switchLoading(false, this.props.url); }); } getBadgeCount(title: string): number { const messageCountInTitle = /^\((\d+)\)/.exec(title); return messageCountInTitle ? Number(messageCountInTitle[1]) : 0; } showNotificationSettings(): void { this.send("show-notification-settings"); } show(): void { // Do not show WebView if another tab was selected and this tab should be in background. if (!this.props.isActive()) { return; } // To show or hide the loading indicator in the the active tab this.$webviewsContainer.toggle("loaded", !this.loading); this.$el.classList.add("active"); this.focus(); this.props.onTitleChange(); // Injecting preload css in webview to override some css rules (async () => this.getWebContents().insertCSS(preloadCss))(); // Get customCSS again from config util to avoid warning user again const customCss = ConfigUtil.getConfigItem("customCSS", null); this.customCss = customCss; if (customCss) { if (!fs.existsSync(customCss)) { this.customCss = null; ConfigUtil.setConfigItem("customCSS", null); const errorMessage = "The custom css previously set is deleted!"; dialog.showErrorBox("custom css file deleted!", errorMessage); return; } (async () => this.getWebContents().insertCSS(fs.readFileSync(customCss, "utf8")))(); } } focus(): void { this.$el.focus(); // Work around https://github.com/electron/electron/issues/31918 this.$el.shadowRoot?.querySelector("iframe")?.focus(); } hide(): void { this.$el.classList.remove("active"); } load(): void { this.show(); } zoomIn(): void { this.zoomFactor += 0.1; this.getWebContents().setZoomFactor(this.zoomFactor); } zoomOut(): void { this.zoomFactor -= 0.1; this.getWebContents().setZoomFactor(this.zoomFactor); } zoomActualSize(): void { this.zoomFactor = 1; this.getWebContents().setZoomFactor(this.zoomFactor); } logOut(): void { this.send("logout"); } showKeyboardShortcuts(): void { this.send("show-keyboard-shortcuts"); } openDevTools(): void { this.getWebContents().openDevTools(); } back(): void { if (this.getWebContents().canGoBack()) { this.getWebContents().goBack(); this.focus(); } } canGoBackButton(): void { const $backButton = document.querySelector( "#actions-container #back-action", )!; $backButton.classList.toggle("disable", !this.getWebContents().canGoBack()); } forward(): void { if (this.getWebContents().canGoForward()) { this.getWebContents().goForward(); } } reload(): void { this.hide(); // Shows the loading indicator till the webview is reloaded this.$webviewsContainer.remove("loaded"); this.loading = true; this.props.switchLoading(true, this.props.url); this.getWebContents().reload(); } send( channel: Channel, ...args: Parameters ): void { ipcRenderer.sendTo(this.webContentsId, channel, ...args); } }