mirror of
https://github.com/zulip/zulip-desktop.git
synced 2025-10-23 03:31:56 +00:00
487 lines
13 KiB
TypeScript
487 lines
13 KiB
TypeScript
import {clipboard} from "electron/common";
|
||
import {
|
||
BrowserWindow,
|
||
type IpcMainEvent,
|
||
type WebContents,
|
||
app,
|
||
dialog,
|
||
powerMonitor,
|
||
session,
|
||
webContents,
|
||
} from "electron/main";
|
||
import {Buffer} from "node:buffer";
|
||
import crypto from "node:crypto";
|
||
import path from "node:path";
|
||
import process from "node:process";
|
||
|
||
import * as remoteMain from "@electron/remote/main";
|
||
import windowStateKeeper from "electron-window-state";
|
||
|
||
import * as ConfigUtil from "../common/config-util.ts";
|
||
import {bundlePath, bundleUrl, publicPath} from "../common/paths.ts";
|
||
import * as t from "../common/translation-util.ts";
|
||
import type {RendererMessage} from "../common/typed-ipc.ts";
|
||
import type {MenuProperties} from "../common/types.ts";
|
||
|
||
import {appUpdater, shouldQuitForUpdate} from "./autoupdater.ts";
|
||
import * as BadgeSettings from "./badge-settings.ts";
|
||
import handleExternalLink from "./handle-external-link.ts";
|
||
import * as AppMenu from "./menu.ts";
|
||
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request.ts";
|
||
import {sentryInit} from "./sentry.ts";
|
||
import {setAutoLaunch} from "./startup.ts";
|
||
import {ipcMain, send} from "./typed-ipc-main.ts";
|
||
|
||
import "gatemaker/electron-setup.js"; // eslint-disable-line import/no-unassigned-import
|
||
|
||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||
const {GDK_BACKEND} = process.env;
|
||
|
||
// Initialize sentry for main process
|
||
sentryInit();
|
||
|
||
let mainWindowState: windowStateKeeper.State;
|
||
|
||
// Prevent window being garbage collected
|
||
let mainWindow: BrowserWindow;
|
||
let badgeCount: number;
|
||
|
||
let isQuitting = false;
|
||
|
||
// Load this file in main window
|
||
const mainUrl = new URL("app/renderer/main.html", bundleUrl).href;
|
||
|
||
const permissionCallbacks = new Map<number, (grant: boolean) => void>();
|
||
let nextPermissionCallbackId = 0;
|
||
|
||
const appIcon = path.join(publicPath, "resources/Icon");
|
||
|
||
const iconPath = (): string =>
|
||
appIcon + (process.platform === "win32" ? ".ico" : ".png");
|
||
|
||
// Toggle the app window
|
||
const toggleApp = (): void => {
|
||
if (!mainWindow.isVisible() || mainWindow.isMinimized()) {
|
||
mainWindow.show();
|
||
} else {
|
||
mainWindow.hide();
|
||
}
|
||
};
|
||
|
||
function createMainWindow(): BrowserWindow {
|
||
// Load the previous state with fallback to defaults
|
||
mainWindowState = windowStateKeeper({
|
||
defaultWidth: 1100,
|
||
defaultHeight: 720,
|
||
path: `${app.getPath("userData")}/config`,
|
||
});
|
||
|
||
const win = new BrowserWindow({
|
||
// This settings needs to be saved in config
|
||
title: "Zulip",
|
||
icon: iconPath(),
|
||
x: mainWindowState.x,
|
||
y: mainWindowState.y,
|
||
width: mainWindowState.width,
|
||
height: mainWindowState.height,
|
||
minWidth: 500,
|
||
minHeight: 400,
|
||
webPreferences: {
|
||
preload: path.join(bundlePath, "renderer.cjs"),
|
||
sandbox: false,
|
||
webviewTag: true,
|
||
},
|
||
show: false,
|
||
});
|
||
remoteMain.enable(win.webContents);
|
||
|
||
win.on("focus", () => {
|
||
send(win.webContents, "focus");
|
||
});
|
||
|
||
(async () => win.loadURL(mainUrl))();
|
||
|
||
// Keep the app running in background on close event
|
||
win.on("close", (event) => {
|
||
if (ConfigUtil.getConfigItem("quitOnClose", false)) {
|
||
app.quit();
|
||
}
|
||
|
||
if (!isQuitting && !shouldQuitForUpdate()) {
|
||
event.preventDefault();
|
||
|
||
if (process.platform === "darwin") {
|
||
if (win.isFullScreen()) {
|
||
win.setFullScreen(false);
|
||
win.once("leave-full-screen", () => {
|
||
app.hide();
|
||
});
|
||
} else {
|
||
app.hide();
|
||
}
|
||
} else {
|
||
win.hide();
|
||
}
|
||
}
|
||
});
|
||
|
||
win.setTitle("Zulip");
|
||
|
||
win.on("enter-full-screen", () => {
|
||
send(win.webContents, "enter-fullscreen");
|
||
});
|
||
|
||
win.on("leave-full-screen", () => {
|
||
send(win.webContents, "leave-fullscreen");
|
||
});
|
||
|
||
// To destroy tray icon when navigate to a new URL
|
||
win.webContents.on("will-navigate", (event) => {
|
||
if (event) {
|
||
send(win.webContents, "destroytray");
|
||
}
|
||
});
|
||
|
||
// Let us register listeners on the window, so we can update the state
|
||
// automatically (the listeners will be removed when the window is closed)
|
||
// and restore the maximized or full screen state
|
||
mainWindowState.manage(win);
|
||
|
||
return win;
|
||
}
|
||
|
||
(async () => {
|
||
if (!app.requestSingleInstanceLock()) {
|
||
app.quit();
|
||
return;
|
||
}
|
||
|
||
await app.whenReady();
|
||
|
||
if (process.env.GDK_BACKEND !== GDK_BACKEND) {
|
||
console.warn(
|
||
"Reverting GDK_BACKEND to work around https://github.com/electron/electron/issues/28436",
|
||
);
|
||
if (GDK_BACKEND === undefined) {
|
||
delete process.env.GDK_BACKEND;
|
||
} else {
|
||
process.env.GDK_BACKEND = GDK_BACKEND;
|
||
}
|
||
}
|
||
|
||
// Used for notifications on Windows
|
||
app.setAppUserModelId("org.zulip.zulip-electron");
|
||
|
||
remoteMain.initialize();
|
||
|
||
app.on("second-instance", () => {
|
||
if (mainWindow) {
|
||
if (mainWindow.isMinimized()) {
|
||
mainWindow.restore();
|
||
}
|
||
|
||
mainWindow.show();
|
||
}
|
||
});
|
||
|
||
ipcMain.on(
|
||
"permission-callback",
|
||
(event, permissionCallbackId: number, grant: boolean) => {
|
||
permissionCallbacks.get(permissionCallbackId)?.(grant);
|
||
permissionCallbacks.delete(permissionCallbackId);
|
||
},
|
||
);
|
||
|
||
// This event is only available on macOS. Triggers when you click on the dock icon.
|
||
app.on("activate", () => {
|
||
mainWindow.show();
|
||
});
|
||
|
||
app.on("web-contents-created", (_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()}`);
|
||
|
||
function configureSpellChecker() {
|
||
const enable = ConfigUtil.getConfigItem("enableSpellchecker", true);
|
||
if (enable && process.platform !== "darwin") {
|
||
ses.setSpellCheckerLanguages(
|
||
ConfigUtil.getConfigItem("spellcheckerLanguages", null) ?? [],
|
||
);
|
||
}
|
||
|
||
ses.setSpellCheckerEnabled(enable);
|
||
}
|
||
|
||
configureSpellChecker();
|
||
ipcMain.on("configure-spell-checker", configureSpellChecker);
|
||
|
||
const clipboardSigKey = crypto.randomBytes(32);
|
||
|
||
ipcMain.on("new-clipboard-key", (event) => {
|
||
const key = crypto.randomBytes(32);
|
||
const hmac = crypto.createHmac("sha256", clipboardSigKey);
|
||
hmac.update(key);
|
||
event.returnValue = {key, sig: hmac.digest()};
|
||
});
|
||
|
||
ipcMain.handle("poll-clipboard", (event, key, sig) => {
|
||
// Check that the key was generated here.
|
||
const hmac = crypto.createHmac("sha256", clipboardSigKey);
|
||
hmac.update(key);
|
||
if (!crypto.timingSafeEqual(sig, hmac.digest())) return;
|
||
|
||
try {
|
||
// Check that the data on the clipboard was encrypted to the key.
|
||
const data = Buffer.from(clipboard.readText(), "hex");
|
||
const iv = data.slice(0, 12);
|
||
const ciphertext = data.slice(12, -16);
|
||
const authTag = data.slice(-16);
|
||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv, {
|
||
authTagLength: 16,
|
||
});
|
||
decipher.setAuthTag(authTag);
|
||
return (
|
||
decipher.update(ciphertext, undefined, "utf8") + decipher.final("utf8")
|
||
);
|
||
} catch {
|
||
// If the parsing or decryption failed in any way,
|
||
// the correct token hasn’t been copied yet; try
|
||
// again next time.
|
||
return undefined;
|
||
}
|
||
});
|
||
|
||
AppMenu.setMenu({
|
||
tabs: [],
|
||
});
|
||
mainWindow = createMainWindow();
|
||
|
||
// Auto-hide menu bar on Windows + Linux
|
||
if (process.platform !== "darwin") {
|
||
const shouldHideMenu = ConfigUtil.getConfigItem("autoHideMenubar", false);
|
||
mainWindow.autoHideMenuBar = shouldHideMenu;
|
||
mainWindow.setMenuBarVisibility(!shouldHideMenu);
|
||
}
|
||
|
||
const page = mainWindow.webContents;
|
||
|
||
page.on("dom-ready", () => {
|
||
if (ConfigUtil.getConfigItem("startMinimized", false)) {
|
||
mainWindow.hide();
|
||
} else {
|
||
mainWindow.show();
|
||
}
|
||
});
|
||
|
||
ipcMain.on("fetch-user-agent", (event) => {
|
||
event.returnValue = session
|
||
.fromPartition("persist:webviewsession")
|
||
.getUserAgent();
|
||
});
|
||
|
||
ipcMain.handle("get-server-settings", async (event, domain: string) =>
|
||
_getServerSettings(domain, ses),
|
||
);
|
||
|
||
ipcMain.handle("save-server-icon", async (event, url: string) =>
|
||
_saveServerIcon(url, ses),
|
||
);
|
||
|
||
ipcMain.handle("is-online", async (event, url: string) =>
|
||
_isOnline(url, ses),
|
||
);
|
||
|
||
page.once("did-frame-finish-load", async () => {
|
||
// Initiate auto-updates on MacOS and Windows
|
||
if (ConfigUtil.getConfigItem("autoUpdate", true)) {
|
||
await appUpdater();
|
||
}
|
||
});
|
||
|
||
app.on(
|
||
"certificate-error",
|
||
(
|
||
event,
|
||
webContents,
|
||
urlString,
|
||
error,
|
||
certificate,
|
||
callback,
|
||
isMainFrame,
|
||
// eslint-disable-next-line max-params
|
||
) => {
|
||
if (isMainFrame) {
|
||
const url = new URL(urlString);
|
||
dialog.showErrorBox(
|
||
t.__("Certificate error"),
|
||
t.__(
|
||
"The server presented an invalid certificate for {{{origin}}}:\n\n{{{error}}}",
|
||
{origin: url.origin, error},
|
||
),
|
||
);
|
||
}
|
||
},
|
||
);
|
||
|
||
ses.setPermissionRequestHandler(
|
||
(webContents, permission, callback, details) => {
|
||
const {origin} = new URL(details.requestingUrl);
|
||
const permissionCallbackId = nextPermissionCallbackId++;
|
||
permissionCallbacks.set(permissionCallbackId, callback);
|
||
send(
|
||
page,
|
||
"permission-request",
|
||
{
|
||
webContentsId:
|
||
webContents.id === mainWindow.webContents.id
|
||
? null
|
||
: webContents.id,
|
||
origin,
|
||
permission,
|
||
},
|
||
permissionCallbackId,
|
||
);
|
||
},
|
||
);
|
||
|
||
// Temporarily remove this event
|
||
// powerMonitor.on('resume', () => {
|
||
// mainWindow.reload();
|
||
// send(page, 'destroytray');
|
||
// });
|
||
|
||
ipcMain.on("focus-app", () => {
|
||
mainWindow.show();
|
||
});
|
||
|
||
ipcMain.on("quit-app", () => {
|
||
app.quit();
|
||
});
|
||
|
||
// Reload full app not just webview, useful in debugging
|
||
ipcMain.on("reload-full-app", () => {
|
||
mainWindow.reload();
|
||
send(page, "destroytray");
|
||
});
|
||
|
||
ipcMain.on("clear-app-settings", () => {
|
||
mainWindowState.unmanage();
|
||
app.relaunch();
|
||
app.exit();
|
||
});
|
||
|
||
ipcMain.on("toggle-app", () => {
|
||
toggleApp();
|
||
});
|
||
|
||
ipcMain.on("toggle-badge-option", () => {
|
||
BadgeSettings.updateBadge(badgeCount, mainWindow);
|
||
});
|
||
|
||
ipcMain.on("toggle-menubar", (_event, showMenubar: boolean) => {
|
||
mainWindow.autoHideMenuBar = showMenubar;
|
||
mainWindow.setMenuBarVisibility(!showMenubar);
|
||
send(page, "toggle-autohide-menubar", showMenubar, true);
|
||
});
|
||
|
||
ipcMain.on("update-badge", (_event, messageCount: number) => {
|
||
badgeCount = messageCount;
|
||
BadgeSettings.updateBadge(badgeCount, mainWindow);
|
||
send(page, "tray", messageCount);
|
||
});
|
||
|
||
ipcMain.on("update-taskbar-icon", (_event, data: string, text: string) => {
|
||
BadgeSettings.updateTaskbarIcon(data, text, mainWindow);
|
||
});
|
||
|
||
ipcMain.on(
|
||
"forward-message",
|
||
<Channel extends keyof RendererMessage>(
|
||
_event: IpcMainEvent,
|
||
listener: Channel,
|
||
...parameters: Parameters<RendererMessage[Channel]>
|
||
) => {
|
||
send(page, listener, ...parameters);
|
||
},
|
||
);
|
||
|
||
ipcMain.on(
|
||
"forward-to",
|
||
<Channel extends keyof RendererMessage>(
|
||
_event: IpcMainEvent,
|
||
webContentsId: number,
|
||
listener: Channel,
|
||
...parameters: Parameters<RendererMessage[Channel]>
|
||
) => {
|
||
const contents = webContents.fromId(webContentsId);
|
||
if (contents !== undefined) {
|
||
send(contents, listener, ...parameters);
|
||
}
|
||
},
|
||
);
|
||
|
||
ipcMain.on("update-menu", (_event, properties: MenuProperties) => {
|
||
AppMenu.setMenu(properties);
|
||
if (properties.activeTabIndex !== undefined) {
|
||
const activeTab = properties.tabs[properties.activeTabIndex];
|
||
mainWindow.setTitle(`Zulip - ${activeTab.label}`);
|
||
}
|
||
});
|
||
|
||
ipcMain.on("toggleAutoLauncher", async (_event, AutoLaunchValue: boolean) => {
|
||
await setAutoLaunch(AutoLaunchValue);
|
||
});
|
||
|
||
ipcMain.on(
|
||
"realm-name-changed",
|
||
(_event, serverURL: string, realmName: string) => {
|
||
send(page, "update-realm-name", serverURL, realmName);
|
||
},
|
||
);
|
||
|
||
ipcMain.on(
|
||
"realm-icon-changed",
|
||
(_event, serverURL: string, iconURL: string) => {
|
||
send(page, "update-realm-icon", serverURL, iconURL);
|
||
},
|
||
);
|
||
|
||
ipcMain.on("save-last-tab", (_event, index: number) => {
|
||
ConfigUtil.setConfigItem("lastActiveTab", index);
|
||
});
|
||
|
||
ipcMain.on("focus-this-webview", (event) => {
|
||
send(page, "focus-webview-with-id", event.sender.id);
|
||
mainWindow.show();
|
||
});
|
||
|
||
// Update user idle status for each realm after every 15s
|
||
const idleCheckInterval = 15 * 1000; // 15 seconds
|
||
setInterval(() => {
|
||
// Set user idle if no activity in 1 second (idleThresholdSeconds)
|
||
const idleThresholdSeconds = 1; // 1 second
|
||
const idleState = powerMonitor.getSystemIdleState(idleThresholdSeconds);
|
||
if (idleState === "active") {
|
||
send(page, "set-active");
|
||
} else {
|
||
send(page, "set-idle");
|
||
}
|
||
}, idleCheckInterval);
|
||
})();
|
||
|
||
app.on("before-quit", () => {
|
||
isQuitting = true;
|
||
});
|
||
|
||
// Send crash reports
|
||
process.on("uncaughtException", (error) => {
|
||
console.error(error);
|
||
console.error(error.stack);
|
||
});
|