diff --git a/electron.vite.config.ts b/electron.vite.config.ts index dc5f95daa..f2d8572d9 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ rollupOptions: { input: { index: "emain/preload.ts", + "preload-webview": "emain/preload-webview.ts", }, output: { format: "cjs", diff --git a/emain/emain.ts b/emain/emain.ts index dd7013cc7..d87c93100 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -9,6 +9,7 @@ import * as path from "path"; import { PNG } from "pngjs"; import * as readline from "readline"; import { sprintf } from "sprintf-js"; +import { Readable } from "stream"; import { debounce } from "throttle-debounce"; import * as util from "util"; import winston from "winston"; @@ -581,6 +582,110 @@ electron.ipcMain.on("open-external", (event, url) => { } }); +type UrlInSessionResult = { + stream: Readable; + mimeType: string; + fileName: string; +}; + +function getSingleHeaderVal(headers: Record, key: string): string { + const val = headers[key]; + if (val == null) { + return null; + } + if (Array.isArray(val)) { + return val[0]; + } + return val; +} + +function cleanMimeType(mimeType: string): string { + if (mimeType == null) { + return null; + } + const parts = mimeType.split(";"); + return parts[0].trim(); +} + +function getFileNameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const filename = pathname.substring(pathname.lastIndexOf("/") + 1); + return filename; + } catch (e) { + return null; + } +} + +function getUrlInSession(session: Electron.Session, url: string): Promise { + return new Promise((resolve, reject) => { + // Handle data URLs directly + if (url.startsWith("data:")) { + const parts = url.split(","); + if (parts.length < 2) { + return reject(new Error("Invalid data URL")); + } + const header = parts[0]; // Get the data URL header (e.g., data:image/png;base64) + const base64Data = parts[1]; // Get the base64 data part + const mimeType = header.split(";")[0].slice(5); // Extract the MIME type (after "data:") + const buffer = Buffer.from(base64Data, "base64"); + const readable = Readable.from(buffer); + resolve({ stream: readable, mimeType, fileName: "image" }); + return; + } + const request = electron.net.request({ + url, + method: "GET", + session, // Attach the session directly to the request + }); + const readable = new Readable({ + read() {}, // No-op, we'll push data manually + }); + request.on("response", (response) => { + const mimeType = cleanMimeType(getSingleHeaderVal(response.headers, "content-type")); + const fileName = getFileNameFromUrl(url) || "image"; + response.on("data", (chunk) => { + readable.push(chunk); // Push data to the readable stream + }); + response.on("end", () => { + readable.push(null); // Signal the end of the stream + resolve({ stream: readable, mimeType, fileName }); + }); + }); + request.on("error", (err) => { + readable.destroy(err); // Destroy the stream on error + reject(err); + }); + request.end(); + }); +} + +electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { + const menu = new electron.Menu(); + const win = electron.BrowserWindow.fromWebContents(event.sender.hostWebContents); + if (win == null) { + return; + } + menu.append( + new electron.MenuItem({ + label: "Save Image", + click: () => { + const resultP = getUrlInSession(event.sender.session, payload.src); + resultP + .then((result) => { + saveImageFileWithNativeDialog(result.fileName, result.mimeType, result.stream); + }) + .catch((e) => { + console.log("error getting image", e); + }); + }, + }) + ); + const { x, y } = electron.screen.getCursorScreenPoint(); + const windowPos = win.getPosition(); + menu.popup({ window: win, x: x - windowPos[0], y: y - windowPos[1] }); +}); + electron.ipcMain.on("download", (event, payload) => { const window = electron.BrowserWindow.fromWebContents(event.sender); const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?path=" + encodeURIComponent(payload.filePath); @@ -702,6 +807,57 @@ async function createNewWaveWindow(): Promise { newBrowserWindow.show(); } +function saveImageFileWithNativeDialog(defaultFileName: string, mimeType: string, readStream: Readable) { + if (defaultFileName == null || defaultFileName == "") { + defaultFileName = "image"; + } + const window = electron.BrowserWindow.getFocusedWindow(); // Get the current window context + const mimeToExtension: { [key: string]: string } = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", + "image/tiff": "tiff", + "image/heic": "heic", + }; + function addExtensionIfNeeded(fileName: string, mimeType: string): string { + const extension = mimeToExtension[mimeType]; + if (!path.extname(fileName) && extension) { + return `${fileName}.${extension}`; + } + return fileName; + } + defaultFileName = addExtensionIfNeeded(defaultFileName, mimeType); + electron.dialog + .showSaveDialog(window, { + title: "Save Image", + defaultPath: defaultFileName, + filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "heic"] }], + }) + .then((file) => { + if (file.canceled) { + return; + } + const writeStream = fs.createWriteStream(file.filePath); + readStream.pipe(writeStream); + writeStream.on("finish", () => { + console.log("saved file", file.filePath); + }); + writeStream.on("error", (err) => { + console.log("error saving file (writeStream)", err); + readStream.destroy(); + }); + readStream.on("error", (err) => { + console.error("error saving file (readStream)", err); + writeStream.destroy(); // Stop the write stream + }); + }) + .catch((err) => { + console.log("error trying to save file", err); + }); +} + electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { diff --git a/emain/menu.ts b/emain/menu.ts index 646162b3d..e5406a8de 100644 --- a/emain/menu.ts +++ b/emain/menu.ts @@ -11,6 +11,16 @@ type AppMenuCallbacks = { relaunchBrowserWindows: () => Promise; }; +function getWindowWebContents(window: electron.BaseWindow): electron.WebContents { + if (window == null) { + return null; + } + if (window instanceof electron.BrowserWindow) { + return window.webContents; + } + return null; +} + function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { const fileMenu: Electron.MenuItemConstructorOptions[] = [ { @@ -30,7 +40,7 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { { label: "About Wave Terminal", click: (_, window) => { - window?.webContents.send("menu-item-about"); + getWindowWebContents(window)?.send("menu-item-about"); }, }, { @@ -122,21 +132,29 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { label: "Actual Size", accelerator: "CommandOrControl+0", click: (_, window) => { - window.webContents.setZoomFactor(1); + getWindowWebContents(window)?.setZoomFactor(1); }, }, { label: "Zoom In", accelerator: "CommandOrControl+=", click: (_, window) => { - window.webContents.setZoomFactor(window.webContents.getZoomFactor() + 0.2); + const wc = getWindowWebContents(window); + if (wc == null) { + return; + } + wc.setZoomFactor(wc.getZoomFactor() + 0.2); }, }, { label: "Zoom In (hidden)", accelerator: "CommandOrControl+Shift+=", click: (_, window) => { - window.webContents.setZoomFactor(window.webContents.getZoomFactor() + 0.2); + const wc = getWindowWebContents(window); + if (wc == null) { + return; + } + wc.setZoomFactor(wc.getZoomFactor() + 0.2); }, visible: false, acceleratorWorksWhenHidden: true, @@ -145,7 +163,11 @@ function getAppMenu(callbacks: AppMenuCallbacks): Electron.Menu { label: "Zoom Out", accelerator: "CommandOrControl+-", click: (_, window) => { - window.webContents.setZoomFactor(window.webContents.getZoomFactor() - 0.2); + const wc = getWindowWebContents(window); + if (wc == null) { + return; + } + wc.setZoomFactor(wc.getZoomFactor() - 0.2); }, }, { diff --git a/emain/platform.ts b/emain/platform.ts index 22d5e8651..1d6d06076 100644 --- a/emain/platform.ts +++ b/emain/platform.ts @@ -36,6 +36,9 @@ ipcMain.on("get-user-name", (event) => { ipcMain.on("get-host-name", (event) => { event.returnValue = os.hostname(); }); +ipcMain.on("get-webview-preload", (event) => { + event.returnValue = path.join(getElectronAppBasePath(), "preload", "preload-webview.cjs"); +}); // must match golang function getWaveHomeDir() { diff --git a/emain/preload-webview.ts b/emain/preload-webview.ts new file mode 100644 index 000000000..e52df5201 --- /dev/null +++ b/emain/preload-webview.ts @@ -0,0 +1,28 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +const { ipcRenderer } = require("electron"); + +document.addEventListener("contextmenu", (event) => { + console.log("contextmenu event", event); + if (event.target == null) { + return; + } + const targetElement = event.target as HTMLElement; + // Check if the right-click is on an image + if (targetElement.tagName === "IMG") { + setTimeout(() => { + if (event.defaultPrevented) { + return; + } + event.preventDefault(); + const imgElem = targetElement as HTMLImageElement; + const imageUrl = imgElem.src; + ipcRenderer.send("webview-image-contextmenu", { src: imageUrl }); + }, 50); + return; + } + // do nothing +}); + +console.log("loaded wave preload-webview.ts"); diff --git a/emain/preload.ts b/emain/preload.ts index 4ad0bb2c4..6368812f3 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld("api", { getHostName: () => ipcRenderer.sendSync("get-host-name"), getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getDocsiteUrl: () => ipcRenderer.sendSync("get-docsite-url"), + getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), openNewWindow: () => ipcRenderer.send("open-new-window"), showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position), onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)), diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index c5aed8af7..6c771fae7 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -16,6 +16,19 @@ import * as jotai from "jotai"; import React, { memo, useEffect, useState } from "react"; import "./webview.less"; +let webviewPreloadUrl = null; + +function getWebviewPreloadUrl() { + if (webviewPreloadUrl == null) { + webviewPreloadUrl = getApi().getWebviewPreload(); + console.log("webviewPreloadUrl", webviewPreloadUrl); + } + if (webviewPreloadUrl == null) { + return null; + } + return "file://" + webviewPreloadUrl; +} + export class WebViewModel implements ViewModel { viewType: string; blockId: string; @@ -501,6 +514,7 @@ const WebView = memo(({ model }: WebViewProps) => { src={metaUrlInitial} data-blockid={model.blockId} data-webcontentsid={webContentsId} // needed for emain + preload={getWebviewPreloadUrl()} // @ts-ignore This is a discrepancy between the React typing and the Chromium impl for webviewTag. Chrome webviewTag expects a string, while React expects a boolean. allowpopups="true" > diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 14ef43570..cc7296747 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -57,6 +57,7 @@ declare global { getEnv: (varName: string) => string; getUserName: () => string; getHostName: () => string; + getWebviewPreload: () => string; getAboutModalDetails: () => AboutModalDetails; getDocsiteUrl: () => string; showContextMenu: (menu?: ElectronContextMenuItem[]) => void;