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, html} from "../../../common/html.js"; import * as t from "../../../common/translation-util.js"; import type {RendererMessage} from "../../../common/typed-ipc.js"; import type {TabRole} from "../../../common/types.js"; import preloadCss from "../../css/preload.css?raw"; 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 WebViewProperties = { $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; unsupportedMessage?: string; }; export default class WebView { static templateHtml(properties: WebViewProperties): Html { return html`
${properties.unsupportedMessage ?? ""} ×
`; } static async create(properties: WebViewProperties): Promise { const $pane = generateNodeFromHtml( WebView.templateHtml(properties), ) as HTMLElement; properties.$root.append($pane); const $webview: HTMLElement = $pane.querySelector(":scope > webview")!; await new Promise((resolve) => { $webview.addEventListener( "did-attach", () => { 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( `${properties.tabIndex}`, )}"]`; const webContentsId: unknown = await properties.rootWebContents.executeJavaScript( `(${getWebContentsIdFunction.toString()})(${JSON.stringify(selector)})`, ); if (typeof webContentsId !== "number") { throw new TypeError("Failed to get WebContents ID"); } return new WebView(properties, $pane, $webview, webContentsId); } badgeCount = 0; loading = true; private customCss: string | false | null; private readonly $webviewsContainer: DOMTokenList; private readonly $unsupported: HTMLElement; private readonly $unsupportedMessage: HTMLElement; private readonly $unsupportedDismiss: HTMLElement; private unsupportedDismissed = false; private constructor( readonly properties: WebViewProperties, private readonly $pane: HTMLElement, private readonly $webview: HTMLElement, readonly webContentsId: number, ) { this.customCss = ConfigUtil.getConfigItem("customCSS", null); this.$webviewsContainer = document.querySelector( "#webviews-container", )!.classList; this.$unsupported = $pane.querySelector(".webview-unsupported")!; this.$unsupportedMessage = $pane.querySelector( ".webview-unsupported-message", )!; this.$unsupportedDismiss = $pane.querySelector( ".webview-unsupported-dismiss", )!; this.registerListeners(); } destroy(): void { this.$pane.remove(); } getWebContents(): WebContents { return remote.webContents.fromId(this.webContentsId)!; } showNotificationSettings(): void { this.send("show-notification-settings"); } focus(): void { this.$webview.focus(); // Work around https://github.com/electron/electron/issues/31918 this.$webview.shadowRoot?.querySelector("iframe")?.focus(); } hide(): void { this.$pane.classList.remove("active"); } load(): void { this.show(); } zoomIn(): void { this.getWebContents().zoomLevel += 0.5; } zoomOut(): void { this.getWebContents().zoomLevel -= 0.5; } zoomActualSize(): void { this.getWebContents().zoomLevel = 0; } logOut(): void { this.send("logout"); } showKeyboardShortcuts(): void { this.send("show-keyboard-shortcuts"); } openDevTools(): void { this.getWebContents().openDevTools(); } back(): void { if (this.getWebContents().navigationHistory.canGoBack()) { this.getWebContents().navigationHistory.goBack(); this.focus(); } } canGoBackButton(): void { const $backButton = document.querySelector( "#actions-container #back-action", )!; $backButton.classList.toggle( "disable", !this.getWebContents().navigationHistory.canGoBack(), ); } forward(): void { if (this.getWebContents().navigationHistory.canGoForward()) { this.getWebContents().navigationHistory.goForward(); } } reload(): void { this.hide(); // Shows the loading indicator till the webview is reloaded this.$webviewsContainer.remove("loaded"); this.loading = true; this.properties.switchLoading(true, this.properties.url); this.getWebContents().reload(); } setUnsupportedMessage(unsupportedMessage: string | undefined) { this.$unsupported.hidden = unsupportedMessage === undefined || this.unsupportedDismissed; this.$unsupportedMessage.textContent = unsupportedMessage ?? ""; } send( channel: Channel, ...arguments_: Parameters ): void { ipcRenderer.send("forward-to", this.webContentsId, channel, ...arguments_); } private registerListeners(): void { const webContents = this.getWebContents(); if (shouldSilentWebview) { webContents.setAudioMuted(true); } webContents.on("page-title-updated", (_event, title) => { this.badgeCount = this.getBadgeCount(title); this.properties.onTitleChange(); }); this.$webview.addEventListener("did-navigate-in-page", () => { this.canGoBackButton(); }); this.$webview.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.$webview.addEventListener("dom-ready", () => { this.loading = false; this.properties.switchLoading(false, this.properties.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.properties.url.includes("network.html")) { this.properties.onNetworkError(this.properties.index); } } }); this.$webview.addEventListener("did-start-loading", () => { this.properties.switchLoading(true, this.properties.url); }); this.$webview.addEventListener("did-stop-loading", () => { this.properties.switchLoading(false, this.properties.url); }); this.$unsupportedDismiss.addEventListener("click", () => { this.unsupportedDismissed = true; this.$unsupported.hidden = true; }); webContents.on("zoom-changed", (event, zoomDirection) => { if (zoomDirection === "in") this.zoomIn(); else if (zoomDirection === "out") this.zoomOut(); }); } private getBadgeCount(title: string): number { const messageCountInTitle = /^\((\d+)\)/.exec(title); return messageCountInTitle ? Number(messageCountInTitle[1]) : 0; } private show(): void { // Do not show WebView if another tab was selected and this tab should be in background. if (!this.properties.isActive()) { return; } // To show or hide the loading indicator in the active tab this.$webviewsContainer.toggle("loaded", !this.loading); this.$pane.classList.add("active"); this.focus(); this.properties.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 = t.__("The custom CSS previously set is deleted."); dialog.showErrorBox(t.__("Custom CSS file deleted"), errorMessage); return; } (async () => this.getWebContents().insertCSS(fs.readFileSync(customCss, "utf8")))(); } } }