Move handleExternalLink to main process.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
Anders Kaseorg
2022-03-31 21:10:13 -07:00
parent 27576c95e6
commit 5edffbdf21
10 changed files with 178 additions and 170 deletions

View File

@@ -3,11 +3,7 @@ import fs from "fs";
import os from "os";
import path from "path";
import {html} from "../../../common/html";
export function isUploadsUrl(server: string, url: URL): boolean {
return url.origin === server && url.pathname.startsWith("/user_uploads/");
}
import {html} from "./html";
export async function openBrowser(url: URL): Promise<void> {
if (["http:", "https:", "mailto:"].includes(url.protocol)) {

View File

@@ -4,7 +4,6 @@ import type {MenuProps, ServerConf} from "./types";
export interface MainMessage {
"clear-app-settings": () => void;
"configure-spell-checker": () => void;
downloadFile: (url: string, downloadPath: string) => void;
"fetch-user-agent": () => string;
"focus-app": () => void;
"focus-this-webview": () => void;
@@ -35,8 +34,6 @@ export interface RendererMessage {
back: () => void;
"copy-zulip-url": () => void;
destroytray: () => void;
downloadFileCompleted: (filePath: string, fileName: string) => void;
downloadFileFailed: (state: string) => void;
"enter-fullscreen": () => void;
focus: () => void;
"focus-webview-with-id": (webviewId: number) => void;
@@ -55,6 +52,7 @@ export interface RendererMessage {
options: {webContentsId: number | null; origin: string; permission: string},
rendererCallbackId: number,
) => void;
"play-ding-sound": () => void;
"reload-current-viewer": () => void;
"reload-proxy": (showAlert: boolean) => void;
"reload-viewer": () => void;

View File

@@ -0,0 +1,157 @@
import {shell} from "electron/common";
import type {
HandlerDetails,
SaveDialogOptions,
WebContents,
} from "electron/main";
import {Notification, app} from "electron/main";
import fs from "fs";
import path from "path";
import * as ConfigUtil from "../common/config-util";
import * as LinkUtil from "../common/link-util";
import {send} from "./typed-ipc-main";
function isUploadsUrl(server: string, url: URL): boolean {
return url.origin === server && url.pathname.startsWith("/user_uploads/");
}
function downloadFile({
contents,
url,
downloadPath,
completed,
failed,
}: {
contents: WebContents;
url: string;
downloadPath: string;
completed(filePath: string, fileName: string): Promise<void>;
failed(state: string): void;
}) {
contents.downloadURL(url);
contents.session.once("will-download", async (_event: Event, item) => {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
const showDialogOptions: SaveDialogOptions = {
defaultPath: path.join(downloadPath, item.getFilename()),
};
item.setSaveDialogOptions(showDialogOptions);
} else {
const getTimeStamp = (): number => {
const date = new Date();
return date.getTime();
};
const formatFile = (filePath: string): string => {
const fileExtension = path.extname(filePath);
const baseName = path.basename(filePath, fileExtension);
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
const filePath = path.join(downloadPath, item.getFilename());
// Update the name and path of the file if it already exists
const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath: string = fs.existsSync(filePath)
? updatedFilePath
: filePath;
item.setSavePath(setFilePath);
}
const updatedListener = (_event: Event, state: string): void => {
switch (state) {
case "interrupted": {
// Can interrupted to due to network error, cancel download then
console.log(
"Download interrupted, cancelling and fallback to dialog download.",
);
item.cancel();
break;
}
case "progressing": {
if (item.isPaused()) {
item.cancel();
}
// This event can also be used to show progress in percentage in future.
break;
}
default: {
console.info("Unknown updated state of download item");
}
}
};
item.on("updated", updatedListener);
item.once("done", async (_event: Event, state) => {
if (state === "completed") {
await completed(item.getSavePath(), path.basename(item.getSavePath()));
} else {
console.log("Download failed state:", state);
failed(state);
}
// To stop item for listening to updated events of this file
item.removeListener("updated", updatedListener);
});
});
}
export default function handleExternalLink(
contents: WebContents,
details: HandlerDetails,
mainContents: WebContents,
): void {
const url = new URL(details.url);
const downloadPath = ConfigUtil.getConfigItem(
"downloadsPath",
`${app.getPath("downloads")}`,
);
if (isUploadsUrl(new URL(contents.getURL()).origin, url)) {
downloadFile({
contents,
url: url.href,
downloadPath,
async completed(filePath: string, fileName: string) {
const downloadNotification = new Notification({
title: "Download Complete",
body: `Click to show ${fileName} in folder`,
silent: true, // We'll play our own sound - ding.ogg
});
downloadNotification.on("click", () => {
// Reveal file in download folder
shell.showItemInFolder(filePath);
});
downloadNotification.show();
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem("silent", false)) {
send(mainContents, "play-ding-sound");
}
},
failed(state: string) {
// Automatic download failed, so show save dialog prompt and download
// through webview
// Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save
// prompts right after each other)
// Check that the download is not cancelled by user
if (state !== "cancelled") {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
new Notification({
title: "Download Complete",
body: "Download failed",
}).show();
} else {
contents.downloadURL(url.href);
}
}
},
});
} else {
(async () => LinkUtil.openBrowser(url))();
}
}

View File

@@ -1,6 +1,5 @@
import type {IpcMainEvent, SaveDialogOptions, WebContents} from "electron/main";
import type {IpcMainEvent, WebContents} from "electron/main";
import {BrowserWindow, app, dialog, powerMonitor, session} from "electron/main";
import fs from "fs";
import path from "path";
import * as remoteMain from "@electron/remote/main";
@@ -12,6 +11,7 @@ import type {MenuProps} from "../common/types";
import {appUpdater} from "./autoupdater";
import * as BadgeSettings from "./badge-settings";
import handleExternalLink from "./handle-external-link";
import * as AppMenu from "./menu";
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request";
import {sentryInit} from "./sentry";
@@ -172,6 +172,13 @@ function createMainWindow(): BrowserWindow {
mainWindow.show();
});
app.on("web-contents-created", (_event: Event, contents: WebContents) => {
contents.setWindowOpenHandler((details) => {
handleExternalLink(contents, details, page);
return {action: "deny"};
});
});
const ses = session.fromPartition("persist:webviewsession");
ses.setUserAgent(`ZulipElectron/${app.getVersion()} ${ses.getUserAgent()}`);
@@ -354,85 +361,6 @@ ${error}`,
},
);
ipcMain.on(
"downloadFile",
(_event: IpcMainEvent, url: string, downloadPath: string) => {
page.downloadURL(url);
page.session.once("will-download", async (_event: Event, item) => {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
const showDialogOptions: SaveDialogOptions = {
defaultPath: path.join(downloadPath, item.getFilename()),
};
item.setSaveDialogOptions(showDialogOptions);
} else {
const getTimeStamp = (): number => {
const date = new Date();
return date.getTime();
};
const formatFile = (filePath: string): string => {
const fileExtension = path.extname(filePath);
const baseName = path.basename(filePath, fileExtension);
return `${baseName}-${getTimeStamp()}${fileExtension}`;
};
const filePath = path.join(downloadPath, item.getFilename());
// Update the name and path of the file if it already exists
const updatedFilePath = path.join(downloadPath, formatFile(filePath));
const setFilePath: string = fs.existsSync(filePath)
? updatedFilePath
: filePath;
item.setSavePath(setFilePath);
}
const updatedListener = (_event: Event, state: string): void => {
switch (state) {
case "interrupted": {
// Can interrupted to due to network error, cancel download then
console.log(
"Download interrupted, cancelling and fallback to dialog download.",
);
item.cancel();
break;
}
case "progressing": {
if (item.isPaused()) {
item.cancel();
}
// This event can also be used to show progress in percentage in future.
break;
}
default: {
console.info("Unknown updated state of download item");
}
}
};
item.on("updated", updatedListener);
item.once("done", (_event: Event, state) => {
if (state === "completed") {
send(
page,
"downloadFileCompleted",
item.getSavePath(),
path.basename(item.getSavePath()),
);
} else {
console.log("Download failed state:", state);
send(page, "downloadFileFailed", state);
}
// To stop item for listening to updated events of this file
item.removeListener("updated", updatedListener);
});
});
},
);
ipcMain.on(
"realm-name-changed",
(_event: IpcMainEvent, serverURL: string, realmName: string) => {

View File

@@ -1,71 +0,0 @@
import {shell} from "electron/common";
import type {HandlerDetails} from "electron/renderer";
import {app} from "@electron/remote";
import * as ConfigUtil from "../../../common/config-util";
import {ipcRenderer} from "../typed-ipc-renderer";
import * as LinkUtil from "../utils/link-util";
import type WebView from "./webview";
const dingSound = new Audio("../resources/sounds/ding.ogg");
export default function handleExternalLink(
this: WebView,
details: HandlerDetails,
): void {
const url = new URL(details.url);
const downloadPath = ConfigUtil.getConfigItem(
"downloadsPath",
`${app.getPath("downloads")}`,
);
if (LinkUtil.isUploadsUrl(this.props.url, url)) {
ipcRenderer.send("downloadFile", url.href, downloadPath);
ipcRenderer.once(
"downloadFileCompleted",
async (_event: Event, filePath: string, fileName: string) => {
const downloadNotification = new Notification("Download Complete", {
body: `Click to show ${fileName} in folder`,
silent: true, // We'll play our own sound - ding.ogg
});
downloadNotification.addEventListener("click", () => {
// Reveal file in download folder
shell.showItemInFolder(filePath);
});
ipcRenderer.removeAllListeners("downloadFileFailed");
// Play sound to indicate download complete
if (!ConfigUtil.getConfigItem("silent", false)) {
await dingSound.play();
}
},
);
ipcRenderer.once("downloadFileFailed", (_event: Event, state: string) => {
// Automatic download failed, so show save dialog prompt and download
// through webview
// Only do this if it is the automatic download, otherwise show an error (so we aren't showing two save
// prompts right after each other)
// Check that the download is not cancelled by user
if (state !== "cancelled") {
if (ConfigUtil.getConfigItem("promptDownload", false)) {
// We need to create a "new Notification" to display it, but just `Notification(...)` on its own
// doesn't work
// eslint-disable-next-line no-new
new Notification("Download Complete", {
body: "Download failed",
});
} else {
this.getWebContents().downloadURL(url.href);
}
}
ipcRenderer.removeAllListeners("downloadFileCompleted");
});
} else {
(async () => LinkUtil.openBrowser(url))();
}
}

View File

@@ -15,7 +15,6 @@ import * as SystemUtil from "../utils/system-util";
import {generateNodeFromHtml} from "./base";
import {contextMenu} from "./context-menu";
import handleExternalLink from "./handle-external-link";
const shouldSilentWebview = ConfigUtil.getConfigItem("silent", false);
@@ -127,11 +126,6 @@ export default class WebView {
registerListeners(): void {
const webContents = this.getWebContents();
webContents.setWindowOpenHandler((details) => {
handleExternalLink.call(this, details);
return {action: "deny"};
});
if (shouldSilentWebview) {
webContents.setAudioMuted(true);
}

View File

@@ -10,6 +10,7 @@ import * as ConfigUtil from "../../common/config-util";
import * as DNDUtil from "../../common/dnd-util";
import type {DndSettings} from "../../common/dnd-util";
import * as EnterpriseUtil from "../../common/enterprise-util";
import * as LinkUtil from "../../common/link-util";
import Logger from "../../common/logger-util";
import * as Messages from "../../common/messages";
import type {NavItem, ServerConf, TabData} from "../../common/types";
@@ -22,7 +23,6 @@ import {PreferenceView} from "./pages/preference/preference";
import {initializeTray} from "./tray";
import {ipcRenderer} from "./typed-ipc-renderer";
import * as DomainUtil from "./utils/domain-util";
import * as LinkUtil from "./utils/link-util";
import ReconnectUtil from "./utils/reconnect-util";
Sentry.init({});
@@ -48,6 +48,8 @@ type ServerOrFunctionalTab = ServerTab | FunctionalTab;
const rootWebContents = remote.getCurrentWebContents();
const dingSound = new Audio("../resources/sounds/ding.ogg");
export class ServerManagerView {
$addServerButton: HTMLButtonElement;
$tabsContainer: Element;
@@ -1177,6 +1179,10 @@ export class ServerManagerView {
ipcRenderer.on("open-network-settings", async () => {
await this.openSettings("Network");
});
ipcRenderer.on("play-ding-sound", async () => {
await dingSound.play();
});
}
}

View File

@@ -1,7 +1,7 @@
import {html} from "../../../../common/html";
import * as LinkUtil from "../../../../common/link-util";
import * as t from "../../../../common/translation-util";
import {generateNodeFromHtml} from "../../components/base";
import * as LinkUtil from "../../utils/link-util";
interface FindAccountsProps {
$root: Element;

View File

@@ -1,11 +1,11 @@
import {dialog} from "@electron/remote";
import {html} from "../../../../common/html";
import * as LinkUtil from "../../../../common/link-util";
import * as t from "../../../../common/translation-util";
import {generateNodeFromHtml} from "../../components/base";
import {ipcRenderer} from "../../typed-ipc-renderer";
import * as DomainUtil from "../../utils/domain-util";
import * as LinkUtil from "../../utils/link-util";
interface NewServerFormProps {
$root: Element;

View File

@@ -1,6 +1,6 @@
import {html} from "../../../../common/html";
import * as LinkUtil from "../../../../common/link-util";
import * as t from "../../../../common/translation-util";
import * as LinkUtil from "../../utils/link-util";
interface ShortcutsSectionProps {
$root: Element;