mirror of
				https://github.com/zulip/zulip-desktop.git
				synced 2025-11-03 21:43:18 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			450 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			450 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import type {Event} from "electron/common";
 | 
						||
import {clipboard} from "electron/common";
 | 
						||
import type {IpcMainEvent, WebContents} from "electron/main";
 | 
						||
import {BrowserWindow, app, dialog, powerMonitor, session} 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.js";
 | 
						||
import {bundlePath, bundleUrl, publicPath} from "../common/paths.js";
 | 
						||
import type {RendererMessage} from "../common/typed-ipc.js";
 | 
						||
import type {MenuProps} from "../common/types.js";
 | 
						||
 | 
						||
import {appUpdater, shouldQuitForUpdate} from "./autoupdater.js";
 | 
						||
import * as BadgeSettings from "./badge-settings.js";
 | 
						||
import handleExternalLink from "./handle-external-link.js";
 | 
						||
import * as AppMenu from "./menu.js";
 | 
						||
import {_getServerSettings, _isOnline, _saveServerIcon} from "./request.js";
 | 
						||
import {sentryInit} from "./sentry.js";
 | 
						||
import {setAutoLaunch} from "./startup.js";
 | 
						||
import {ipcMain, send} from "./typed-ipc-main.js";
 | 
						||
 | 
						||
import "gatemaker/electron-setup"; // 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.js"),
 | 
						||
      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") {
 | 
						||
        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: Event,
 | 
						||
      webContents: WebContents,
 | 
						||
      urlString: string,
 | 
						||
      error: string,
 | 
						||
    ) => {
 | 
						||
      const url = new URL(urlString);
 | 
						||
      dialog.showErrorBox(
 | 
						||
        "Certificate error",
 | 
						||
        `The server presented an invalid certificate for ${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("update-menu", (_event, props: MenuProps) => {
 | 
						||
    AppMenu.setMenu(props);
 | 
						||
    if (props.activeTabIndex !== undefined) {
 | 
						||
      const activeTab = props.tabs[props.activeTabIndex];
 | 
						||
      mainWindow.setTitle(`Zulip - ${activeTab.name}`);
 | 
						||
    }
 | 
						||
  });
 | 
						||
 | 
						||
  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);
 | 
						||
});
 |