element-desktop/src/webcontents-handler.ts

312 lines
10 KiB
TypeScript
Raw Normal View History

/*
2024-09-06 17:56:18 +01:00
Copyright 2021-2024 New Vector Ltd.
2024-09-06 17:56:18 +01:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import {
clipboard,
nativeImage,
Menu,
MenuItem,
shell,
dialog,
ipcMain,
NativeImage,
WebContents,
ContextMenuParams,
DownloadItem,
MenuItemConstructorOptions,
IpcMainEvent,
Event,
2022-12-15 11:00:58 +00:00
} from "electron";
import url from "url";
import fs from "fs";
import fetch from "node-fetch";
import { pipeline } from "stream/promises";
2022-12-15 11:00:58 +00:00
import path from "path";
2022-01-10 12:57:33 +00:00
2022-12-15 11:00:58 +00:00
import { _t } from "./language-helper";
const MAILTO_PREFIX = "mailto:";
2022-12-15 11:00:58 +00:00
const PERMITTED_URL_SCHEMES: string[] = ["http:", "https:", MAILTO_PREFIX];
function safeOpenURL(target: string): void {
// openExternal passes the target to open/start/xdg-open,
// so put fairly stringent limits on what can be opened
// (for instance, open /bin/sh does indeed open a terminal
// with a shell, albeit with no arguments)
const parsedUrl = url.parse(target);
2022-10-13 12:42:33 +01:00
if (PERMITTED_URL_SCHEMES.includes(parsedUrl.protocol!)) {
// explicitly use the URL re-assembled by the url library,
// so we know the url parser has understood all the parts
// of the input string
const newTarget = url.format(parsedUrl);
void shell.openExternal(newTarget);
}
}
function onWindowOrNavigate(ev: Event, target: string): void {
// always prevent the default: if something goes wrong,
// we don't want to end up opening it in the electron
// app, as we could end up opening any sort of random
// url in a window that has node scripting access.
ev.preventDefault();
safeOpenURL(target);
}
function writeNativeImage(filePath: string, img: NativeImage): Promise<void> {
2022-12-15 11:00:58 +00:00
switch (filePath.split(".").pop()?.toLowerCase()) {
case "jpg":
case "jpeg":
return fs.promises.writeFile(filePath, img.toJPEG(100));
case "bmp":
return fs.promises.writeFile(filePath, img.toBitmap());
case "png":
default:
return fs.promises.writeFile(filePath, img.toPNG());
}
}
function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void {
let url = params.linkURL || params.srcURL;
2022-12-15 11:00:58 +00:00
if (url.startsWith("vector://vector/webapp")) {
// Avoid showing a context menu for app icons
if (params.hasImageContents) return;
const baseUrl = vectorConfig.web_base_url ?? "https://app.element.io/";
// Rewrite URL so that it can be used outside the app
url = baseUrl + url.substring(23);
}
const popupMenu = new Menu();
// No point trying to open blob: URLs in an external browser: it ain't gonna work.
2022-12-15 11:00:58 +00:00
if (!url.startsWith("blob:")) {
popupMenu.append(
new MenuItem({
label: url,
click(): void {
safeOpenURL(url);
},
}),
);
}
if (params.hasImageContents) {
2022-12-15 11:00:58 +00:00
popupMenu.append(
new MenuItem({
label: _t("right_click_menu|copy_image"),
2022-12-15 11:00:58 +00:00
accelerator: "c",
click(): void {
webContents.copyImageAt(params.x, params.y);
},
}),
);
}
// No point offering to copy a blob: URL either
2022-12-15 11:00:58 +00:00
if (!url.startsWith("blob:")) {
// Special-case e-mail URLs to strip the `mailto:` like modern browsers do
if (url.startsWith(MAILTO_PREFIX)) {
2022-12-15 11:00:58 +00:00
popupMenu.append(
new MenuItem({
label: _t("right_click_menu|copy_email"),
2022-12-15 11:00:58 +00:00
accelerator: "a",
click(): void {
clipboard.writeText(url.substr(MAILTO_PREFIX.length));
},
}),
);
} else {
2022-12-15 11:00:58 +00:00
popupMenu.append(
new MenuItem({
label: params.hasImageContents
? _t("right_click_menu|copy_image_url")
: _t("right_click_menu|copy_link_url"),
2022-12-15 11:00:58 +00:00
accelerator: "a",
click(): void {
clipboard.writeText(url);
},
}),
);
}
}
// XXX: We cannot easily save a blob from the main process as
// only the renderer can resolve them so don't give the user an option to.
2022-12-15 11:00:58 +00:00
if (params.hasImageContents && !url.startsWith("blob:")) {
popupMenu.append(
new MenuItem({
label: _t("right_click_menu|save_image_as"),
2022-12-15 11:00:58 +00:00
accelerator: "s",
async click(): Promise<void> {
const targetFileName = params.suggestedFilename || params.altText || "image.png";
const { filePath } = await dialog.showSaveDialog({
defaultPath: targetFileName,
});
2022-12-15 11:00:58 +00:00
if (!filePath) return; // user cancelled dialog
try {
if (url.startsWith("data:")) {
await writeNativeImage(filePath, nativeImage.createFromDataURL(url));
} else {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`unexpected response ${resp.statusText}`);
if (!resp.body) throw new Error(`unexpected response has no body ${resp.statusText}`);
await pipeline(resp.body, fs.createWriteStream(filePath));
2022-12-15 11:00:58 +00:00
}
} catch (err) {
console.error(err);
void dialog.showMessageBox({
2022-12-15 11:00:58 +00:00
type: "error",
title: _t("right_click_menu|save_image_as_error_title"),
message: _t("right_click_menu|save_image_as_error_description"),
2022-12-15 11:00:58 +00:00
});
}
2022-12-15 11:00:58 +00:00
},
}),
);
}
// popup() requires an options object even for no options
popupMenu.popup({});
ev.preventDefault();
}
function cutCopyPasteSelectContextMenus(
params: ContextMenuParams,
webContents: WebContents,
): MenuItemConstructorOptions[] {
const options: MenuItemConstructorOptions[] = [];
if (params.misspelledWord) {
2022-12-15 11:00:58 +00:00
params.dictionarySuggestions.forEach((word) => {
options.push({
label: word,
click: () => {
webContents.replaceMisspelling(word);
},
});
});
2022-12-15 11:00:58 +00:00
options.push(
{
type: "separator",
},
2022-12-15 11:00:58 +00:00
{
label: _t("right_click_menu|add_to_dictionary"),
click: () => {
webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord);
2022-12-15 11:00:58 +00:00
},
},
{
type: "separator",
},
);
}
2022-12-15 11:00:58 +00:00
options.push(
{
role: "cut",
label: _t("action|cut"),
2022-12-15 11:00:58 +00:00
accelerator: "t",
enabled: params.editFlags.canCut,
},
{
role: "copy",
label: _t("action|copy"),
2022-12-15 11:00:58 +00:00
accelerator: "c",
enabled: params.editFlags.canCopy,
},
{
role: "paste",
label: _t("action|paste"),
2022-12-15 11:00:58 +00:00
accelerator: "p",
enabled: params.editFlags.canPaste,
},
{
role: "pasteAndMatchStyle",
enabled: params.editFlags.canPaste,
},
{
role: "selectAll",
label: _t("action|select_all"),
2022-12-15 11:00:58 +00:00
accelerator: "a",
enabled: params.editFlags.canSelectAll,
},
);
return options;
}
function onSelectedContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void {
const items = cutCopyPasteSelectContextMenus(params, webContents);
const popupMenu = Menu.buildFromTemplate(items);
// popup() requires an options object even for no options
popupMenu.popup({});
ev.preventDefault();
}
function onEditableContextMenu(ev: Event, params: ContextMenuParams, webContents: WebContents): void {
const items: MenuItemConstructorOptions[] = [
2022-12-15 11:00:58 +00:00
{ role: "undo" },
{ role: "redo", enabled: params.editFlags.canRedo },
{ type: "separator" },
...cutCopyPasteSelectContextMenus(params, webContents),
];
const popupMenu = Menu.buildFromTemplate(items);
// popup() requires an options object even for no options
popupMenu.popup({});
ev.preventDefault();
}
let userDownloadIndex = 0;
const userDownloadMap = new Map<number, string>(); // Map from id to path
2022-12-15 11:00:58 +00:00
ipcMain.on("userDownloadAction", function (ev: IpcMainEvent, { id, open = false }) {
2022-10-13 12:42:33 +01:00
const path = userDownloadMap.get(id);
if (open && path) {
void shell.openPath(path);
}
userDownloadMap.delete(id);
});
export default (webContents: WebContents): void => {
webContents.setWindowOpenHandler((details) => {
safeOpenURL(details.url);
return { action: "deny" };
});
2022-12-15 11:00:58 +00:00
webContents.on("will-navigate", (ev: Event, target: string): void => {
if (target.startsWith("vector://")) return;
return onWindowOrNavigate(ev, target);
});
2022-12-15 11:00:58 +00:00
webContents.on("context-menu", function (ev: Event, params: ContextMenuParams): void {
if (params.linkURL || params.srcURL) {
onLinkContextMenu(ev, params, webContents);
} else if (params.selectionText) {
onSelectedContextMenu(ev, params, webContents);
} else if (params.isEditable) {
onEditableContextMenu(ev, params, webContents);
}
});
2022-12-15 11:00:58 +00:00
webContents.session.on("will-download", (event: Event, item: DownloadItem): void => {
item.once("done", (event, state) => {
if (state === "completed") {
const savePath = item.getSavePath();
const id = userDownloadIndex++;
userDownloadMap.set(id, savePath);
2022-12-15 11:00:58 +00:00
webContents.send("userDownloadCompleted", {
id,
name: path.basename(savePath),
});
}
});
});
};