Browse Source

[web] [desktop] Refactoring IPC (#1398)

- IPC cleanup and refactoring
- Log on unhandled errors and promise rejections
Manav Rathi 1 year ago
parent
commit
03176911ee

+ 4 - 3
desktop/src/main.ts

@@ -27,7 +27,7 @@ import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
 import log, { initLogging } from "./main/log";
 import { createApplicationMenu } from "./main/menu";
 import { isDev } from "./main/util";
-import { setupAutoUpdater } from "./services/appUpdater";
+import { setupAutoUpdater } from "./services/app-update";
 import { initWatcher } from "./services/chokidar";
 
 let appIsQuitting = false;
@@ -142,9 +142,10 @@ const deleteLegacyDiskCacheDirIfExists = async () => {
 };
 
 const attachEventHandlers = (mainWindow: BrowserWindow) => {
-    // Let ipcRenderer know when mainWindow is in the foreground.
+    // Let ipcRenderer know when mainWindow is in the foreground so that it can
+    // in turn inform the renderer process.
     mainWindow.on("focus", () =>
-        mainWindow.webContents.send("app-in-foreground"),
+        mainWindow.webContents.send("mainWindowFocus"),
     );
 };
 

+ 15 - 20
desktop/src/main/ipc.ts

@@ -12,14 +12,11 @@ import type { FSWatcher } from "chokidar";
 import { ipcMain } from "electron/main";
 import {
     appVersion,
-    muteUpdateNotification,
     skipAppUpdate,
     updateAndRestart,
-} from "../services/appUpdater";
-import {
-    clipImageEmbedding,
-    clipTextEmbedding,
-} from "../services/clip-service";
+    updateOnNextRestart,
+} from "../services/app-update";
+import { clipImageEmbedding, clipTextEmbedding } from "../services/clip";
 import { runFFmpegCmd } from "../services/ffmpeg";
 import { getDirFiles } from "../services/fs";
 import {
@@ -27,9 +24,9 @@ import {
     generateImageThumbnail,
 } from "../services/imageProcessor";
 import {
-    clearElectronStore,
-    getEncryptionKey,
-    setEncryptionKey,
+    clearStores,
+    encryptionKey,
+    saveEncryptionKey,
 } from "../services/store";
 import {
     getElectronFilesFromGoogleZip,
@@ -98,26 +95,24 @@ export const attachIPCHandlers = () => {
     // See [Note: Catching exception during .send/.on]
     ipcMain.on("logToDisk", (_, message) => logToDisk(message));
 
-    ipcMain.on("clear-electron-store", () => {
-        clearElectronStore();
-    });
+    ipcMain.on("clearStores", () => clearStores());
 
-    ipcMain.handle("setEncryptionKey", (_, encryptionKey) =>
-        setEncryptionKey(encryptionKey),
+    ipcMain.handle("saveEncryptionKey", (_, encryptionKey) =>
+        saveEncryptionKey(encryptionKey),
     );
 
-    ipcMain.handle("getEncryptionKey", () => getEncryptionKey());
+    ipcMain.handle("encryptionKey", () => encryptionKey());
 
     // - App update
 
-    ipcMain.on("update-and-restart", () => updateAndRestart());
-
-    ipcMain.on("skip-app-update", (_, version) => skipAppUpdate(version));
+    ipcMain.on("updateAndRestart", () => updateAndRestart());
 
-    ipcMain.on("mute-update-notification", (_, version) =>
-        muteUpdateNotification(version),
+    ipcMain.on("updateOnNextRestart", (_, version) =>
+        updateOnNextRestart(version),
     );
 
+    ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version));
+
     // - Conversion
 
     ipcMain.handle("convertToJPEG", (_, fileData, filename) =>

+ 10 - 0
desktop/src/main/log.ts

@@ -19,6 +19,16 @@ export const initLogging = () => {
     log.transports.file.format = "[{y}-{m}-{d}T{h}:{i}:{s}{z}] {text}";
 
     log.transports.console.level = false;
+
+    // Log unhandled errors and promise rejections.
+    log.errorHandler.startCatching({
+        onError: ({ error, errorName }) => {
+            logError(errorName, error);
+            // Prevent the default electron-log actions (e.g. showing a dialog)
+            // from getting triggered.
+            return false;
+        },
+    });
 };
 
 /**

+ 2 - 3
desktop/src/main/menu.ts

@@ -6,7 +6,7 @@ import {
     shell,
 } from "electron";
 import { setIsAppQuitting } from "../main";
-import { forceCheckForUpdateAndNotify } from "../services/appUpdater";
+import { forceCheckForAppUpdates } from "../services/app-update";
 import autoLauncher from "../services/autoLauncher";
 import {
     getHideDockIconPreference,
@@ -26,8 +26,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
     const macOSOnly = (options: MenuItemConstructorOptions[]) =>
         process.platform == "darwin" ? options : [];
 
-    const handleCheckForUpdates = () =>
-        forceCheckForUpdateAndNotify(mainWindow);
+    const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
 
     const handleViewChangelog = () =>
         shell.openExternal(

+ 35 - 40
desktop/src/preload.ts

@@ -52,57 +52,54 @@ import type {
 
 const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion");
 
+const logToDisk = (message: string): void =>
+    ipcRenderer.send("logToDisk", message);
+
 const openDirectory = (dirPath: string): Promise<void> =>
     ipcRenderer.invoke("openDirectory", dirPath);
 
 const openLogDirectory = (): Promise<void> =>
     ipcRenderer.invoke("openLogDirectory");
 
-const logToDisk = (message: string): void =>
-    ipcRenderer.send("logToDisk", message);
+const clearStores = () => ipcRenderer.send("clearStores");
 
-const fsExists = (path: string): Promise<boolean> =>
-    ipcRenderer.invoke("fsExists", path);
+const encryptionKey = (): Promise<string | undefined> =>
+    ipcRenderer.invoke("encryptionKey");
 
-// - AUDIT below this
+const saveEncryptionKey = (encryptionKey: string): Promise<void> =>
+    ipcRenderer.invoke("saveEncryptionKey", encryptionKey);
 
-const registerForegroundEventListener = (onForeground: () => void) => {
-    ipcRenderer.removeAllListeners("app-in-foreground");
-    ipcRenderer.on("app-in-foreground", onForeground);
+const onMainWindowFocus = (cb?: () => void) => {
+    ipcRenderer.removeAllListeners("mainWindowFocus");
+    if (cb) ipcRenderer.on("mainWindowFocus", cb);
 };
 
-const clearElectronStore = () => {
-    ipcRenderer.send("clear-electron-store");
-};
-
-const setEncryptionKey = (encryptionKey: string): Promise<void> =>
-    ipcRenderer.invoke("setEncryptionKey", encryptionKey);
-
-const getEncryptionKey = (): Promise<string> =>
-    ipcRenderer.invoke("getEncryptionKey");
-
 // - App update
 
-const registerUpdateEventListener = (
-    showUpdateDialog: (updateInfo: AppUpdateInfo) => void,
+const onAppUpdateAvailable = (
+    cb?: ((updateInfo: AppUpdateInfo) => void) | undefined,
 ) => {
-    ipcRenderer.removeAllListeners("show-update-dialog");
-    ipcRenderer.on("show-update-dialog", (_, updateInfo: AppUpdateInfo) => {
-        showUpdateDialog(updateInfo);
-    });
+    ipcRenderer.removeAllListeners("appUpdateAvailable");
+    if (cb) {
+        ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) =>
+            cb(updateInfo),
+        );
+    }
 };
 
-const updateAndRestart = () => {
-    ipcRenderer.send("update-and-restart");
-};
+const updateAndRestart = () => ipcRenderer.send("updateAndRestart");
+
+const updateOnNextRestart = (version: string) =>
+    ipcRenderer.send("updateOnNextRestart", version);
 
 const skipAppUpdate = (version: string) => {
-    ipcRenderer.send("skip-app-update", version);
+    ipcRenderer.send("skipAppUpdate", version);
 };
 
-const muteUpdateNotification = (version: string) => {
-    ipcRenderer.send("mute-update-notification", version);
-};
+const fsExists = (path: string): Promise<boolean> =>
+    ipcRenderer.invoke("fsExists", path);
+
+// - AUDIT below this
 
 // - Conversion
 
@@ -303,21 +300,19 @@ const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
 contextBridge.exposeInMainWorld("electron", {
     // - General
     appVersion,
+    logToDisk,
     openDirectory,
-    registerForegroundEventListener,
-    clearElectronStore,
-    getEncryptionKey,
-    setEncryptionKey,
-
-    // - Logging
     openLogDirectory,
-    logToDisk,
+    clearStores,
+    encryptionKey,
+    saveEncryptionKey,
+    onMainWindowFocus,
 
     // - App update
+    onAppUpdateAvailable,
     updateAndRestart,
+    updateOnNextRestart,
     skipAppUpdate,
-    muteUpdateNotification,
-    registerUpdateEventListener,
 
     // - Conversion
     convertToJPEG,

+ 98 - 0
desktop/src/services/app-update.ts

@@ -0,0 +1,98 @@
+import { compareVersions } from "compare-versions";
+import { app, BrowserWindow } from "electron";
+import { default as electronLog } from "electron-log";
+import { autoUpdater } from "electron-updater";
+import { setIsAppQuitting, setIsUpdateAvailable } from "../main";
+import log from "../main/log";
+import { userPreferencesStore } from "../stores/user-preferences";
+import { AppUpdateInfo } from "../types/ipc";
+
+export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
+    autoUpdater.logger = electronLog;
+    autoUpdater.autoDownload = false;
+
+    const oneDay = 1 * 24 * 60 * 60 * 1000;
+    setInterval(() => checkForUpdatesAndNotify(mainWindow), oneDay);
+    checkForUpdatesAndNotify(mainWindow);
+};
+
+/**
+ * Check for app update check ignoring any previously saved skips / mutes.
+ */
+export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => {
+    userPreferencesStore.delete("skipAppVersion");
+    userPreferencesStore.delete("muteUpdateNotificationVersion");
+    checkForUpdatesAndNotify(mainWindow);
+};
+
+const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
+    try {
+        const { updateInfo } = await autoUpdater.checkForUpdates();
+        const { version } = updateInfo;
+
+        log.debug(() => `Checking for updates found version ${version}`);
+
+        if (compareVersions(version, app.getVersion()) <= 0) {
+            log.debug(() => "Skipping update, already at latest version");
+            return;
+        }
+
+        if (version === userPreferencesStore.get("skipAppVersion")) {
+            log.info(`User chose to skip version ${version}`);
+            return;
+        }
+
+        const mutedVersion = userPreferencesStore.get(
+            "muteUpdateNotificationVersion",
+        );
+        if (version === mutedVersion) {
+            log.info(
+                `User has muted update notifications for version ${version}`,
+            );
+            return;
+        }
+
+        const showUpdateDialog = (updateInfo: AppUpdateInfo) =>
+            mainWindow.webContents.send("appUpdateAvailable", updateInfo);
+
+        log.debug(() => "Attempting auto update");
+        autoUpdater.downloadUpdate();
+
+        let timeout: NodeJS.Timeout;
+        const fiveMinutes = 5 * 60 * 1000;
+        autoUpdater.on("update-downloaded", () => {
+            timeout = setTimeout(
+                () => showUpdateDialog({ autoUpdatable: true, version }),
+                fiveMinutes,
+            );
+        });
+        autoUpdater.on("error", (error) => {
+            clearTimeout(timeout);
+            log.error("Auto update failed", error);
+            showUpdateDialog({ autoUpdatable: false, version });
+        });
+
+        setIsUpdateAvailable(true);
+    } catch (e) {
+        log.error("checkForUpdateAndNotify failed", e);
+    }
+};
+
+/**
+ * Return the version of the desktop app
+ *
+ * The return value is of the form `v1.2.3`.
+ */
+export const appVersion = () => `v${app.getVersion()}`;
+
+export const updateAndRestart = () => {
+    log.info("Restarting the app to apply update");
+    setIsAppQuitting(true);
+    autoUpdater.quitAndInstall();
+};
+
+export const updateOnNextRestart = (version: string) =>
+    userPreferencesStore.set("muteUpdateNotificationVersion", version);
+
+export const skipAppUpdate = (version: string) =>
+    userPreferencesStore.set("skipAppVersion", version);

+ 0 - 120
desktop/src/services/appUpdater.ts

@@ -1,120 +0,0 @@
-import { compareVersions } from "compare-versions";
-import { app, BrowserWindow } from "electron";
-import { default as electronLog } from "electron-log";
-import { autoUpdater } from "electron-updater";
-import { setIsAppQuitting, setIsUpdateAvailable } from "../main";
-import log from "../main/log";
-import { AppUpdateInfo } from "../types/ipc";
-import {
-    clearMuteUpdateNotificationVersion,
-    clearSkipAppVersion,
-    getMuteUpdateNotificationVersion,
-    getSkipAppVersion,
-    setMuteUpdateNotificationVersion,
-    setSkipAppVersion,
-} from "./userPreference";
-
-const FIVE_MIN_IN_MICROSECOND = 5 * 60 * 1000;
-const ONE_DAY_IN_MICROSECOND = 1 * 24 * 60 * 60 * 1000;
-
-export function setupAutoUpdater(mainWindow: BrowserWindow) {
-    autoUpdater.logger = electronLog;
-    autoUpdater.autoDownload = false;
-    checkForUpdateAndNotify(mainWindow);
-    setInterval(
-        () => checkForUpdateAndNotify(mainWindow),
-        ONE_DAY_IN_MICROSECOND,
-    );
-}
-
-export function forceCheckForUpdateAndNotify(mainWindow: BrowserWindow) {
-    try {
-        clearSkipAppVersion();
-        clearMuteUpdateNotificationVersion();
-        checkForUpdateAndNotify(mainWindow);
-    } catch (e) {
-        log.error("forceCheckForUpdateAndNotify failed", e);
-    }
-}
-
-async function checkForUpdateAndNotify(mainWindow: BrowserWindow) {
-    try {
-        log.debug(() => "checkForUpdateAndNotify");
-        const { updateInfo } = await autoUpdater.checkForUpdates();
-        log.debug(() => `Update version ${updateInfo.version}`);
-        if (compareVersions(updateInfo.version, app.getVersion()) <= 0) {
-            log.debug(() => "Skipping update, already at latest version");
-            return;
-        }
-        const skipAppVersion = getSkipAppVersion();
-        if (skipAppVersion && updateInfo.version === skipAppVersion) {
-            log.info(`User chose to skip version ${updateInfo.version}`);
-            return;
-        }
-
-        let timeout: NodeJS.Timeout;
-        log.debug(() => "Attempting auto update");
-        autoUpdater.downloadUpdate();
-        const muteUpdateNotificationVersion =
-            getMuteUpdateNotificationVersion();
-        if (
-            muteUpdateNotificationVersion &&
-            updateInfo.version === muteUpdateNotificationVersion
-        ) {
-            log.info(
-                `User has muted update notifications for version ${updateInfo.version}`,
-            );
-            return;
-        }
-        autoUpdater.on("update-downloaded", () => {
-            timeout = setTimeout(
-                () =>
-                    showUpdateDialog(mainWindow, {
-                        autoUpdatable: true,
-                        version: updateInfo.version,
-                    }),
-                FIVE_MIN_IN_MICROSECOND,
-            );
-        });
-        autoUpdater.on("error", (error) => {
-            clearTimeout(timeout);
-            log.error("Auto update failed", error);
-            showUpdateDialog(mainWindow, {
-                autoUpdatable: false,
-                version: updateInfo.version,
-            });
-        });
-
-        setIsUpdateAvailable(true);
-    } catch (e) {
-        log.error("checkForUpdateAndNotify failed", e);
-    }
-}
-
-export function updateAndRestart() {
-    log.info("user quit the app");
-    setIsAppQuitting(true);
-    autoUpdater.quitAndInstall();
-}
-
-/**
- * Return the version of the desktop app
- *
- * The return value is of the form `v1.2.3`.
- */
-export const appVersion = () => `v${app.getVersion()}`;
-
-export function skipAppUpdate(version: string) {
-    setSkipAppVersion(version);
-}
-
-export function muteUpdateNotification(version: string) {
-    setMuteUpdateNotificationVersion(version);
-}
-
-function showUpdateDialog(
-    mainWindow: BrowserWindow,
-    updateInfo: AppUpdateInfo,
-) {
-    mainWindow.webContents.send("show-update-dialog", updateInfo);
-}

+ 0 - 0
desktop/src/services/clip-service.ts → desktop/src/services/clip.ts


+ 8 - 9
desktop/src/services/store.ts

@@ -4,23 +4,22 @@ import { safeStorageStore } from "../stores/safeStorage.store";
 import { uploadStatusStore } from "../stores/upload.store";
 import { watchStore } from "../stores/watch.store";
 
-export const clearElectronStore = () => {
+export const clearStores = () => {
     uploadStatusStore.clear();
     keysStore.clear();
     safeStorageStore.clear();
     watchStore.clear();
 };
 
-export async function setEncryptionKey(encryptionKey: string) {
+export const saveEncryptionKey = async (encryptionKey: string) => {
     const encryptedKey: Buffer = await safeStorage.encryptString(encryptionKey);
     const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
     safeStorageStore.set("encryptionKey", b64EncryptedKey);
-}
+};
 
-export async function getEncryptionKey(): Promise<string> {
+export const encryptionKey = async (): Promise<string | undefined> => {
     const b64EncryptedKey = safeStorageStore.get("encryptionKey");
-    if (b64EncryptedKey) {
-        const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
-        return await safeStorage.decryptString(keyBuffer);
-    }
-}
+    if (!b64EncryptedKey) return undefined;
+    const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
+    return await safeStorage.decryptString(keyBuffer);
+};

+ 1 - 25
desktop/src/services/userPreference.ts

@@ -1,4 +1,4 @@
-import { userPreferencesStore } from "../stores/userPreferences.store";
+import { userPreferencesStore } from "../stores/user-preferences";
 
 export function getHideDockIconPreference() {
     return userPreferencesStore.get("hideDockIcon");
@@ -7,27 +7,3 @@ export function getHideDockIconPreference() {
 export function setHideDockIconPreference(shouldHideDockIcon: boolean) {
     userPreferencesStore.set("hideDockIcon", shouldHideDockIcon);
 }
-
-export function getSkipAppVersion() {
-    return userPreferencesStore.get("skipAppVersion");
-}
-
-export function setSkipAppVersion(version: string) {
-    userPreferencesStore.set("skipAppVersion", version);
-}
-
-export function getMuteUpdateNotificationVersion() {
-    return userPreferencesStore.get("muteUpdateNotificationVersion");
-}
-
-export function setMuteUpdateNotificationVersion(version: string) {
-    userPreferencesStore.set("muteUpdateNotificationVersion", version);
-}
-
-export function clearSkipAppVersion() {
-    userPreferencesStore.delete("skipAppVersion");
-}
-
-export function clearMuteUpdateNotificationVersion() {
-    userPreferencesStore.delete("muteUpdateNotificationVersion");
-}

+ 7 - 2
desktop/src/stores/userPreferences.store.ts → desktop/src/stores/user-preferences.ts

@@ -1,7 +1,12 @@
 import Store, { Schema } from "electron-store";
-import type { UserPreferencesType } from "../types/main";
 
-const userPreferencesSchema: Schema<UserPreferencesType> = {
+interface UserPreferencesSchema {
+    hideDockIcon: boolean;
+    skipAppVersion?: string;
+    muteUpdateNotificationVersion?: string;
+}
+
+const userPreferencesSchema: Schema<UserPreferencesSchema> = {
     hideDockIcon: {
         type: "boolean",
     },

+ 0 - 6
desktop/src/types/main.ts

@@ -29,9 +29,3 @@ export const FILE_PATH_KEYS: {
 export interface SafeStorageStoreType {
     encryptionKey: string;
 }
-
-export interface UserPreferencesType {
-    hideDockIcon: boolean;
-    skipAppVersion: string;
-    muteUpdateNotificationVersion: string;
-}

+ 3 - 0
web/apps/accounts/src/pages/_app.tsx

@@ -1,5 +1,6 @@
 import { CustomHead } from "@/next/components/Head";
 import { setupI18n } from "@/next/i18n";
+import { logUnhandledErrorsAndRejections } from "@/next/log-web";
 import { APPS, APP_TITLES } from "@ente/shared/apps/constants";
 import { Overlay } from "@ente/shared/components/Container";
 import DialogBoxV2 from "@ente/shared/components/DialogBoxV2";
@@ -54,6 +55,8 @@ export default function App({ Component, pageProps }: AppProps) {
 
     useEffect(() => {
         setupI18n().finally(() => setIsI18nReady(true));
+        logUnhandledErrorsAndRejections(true);
+        return () => logUnhandledErrorsAndRejections(false);
     }, []);
 
     const setupPackageName = () => {

+ 6 - 1
web/apps/auth/src/pages/_app.tsx

@@ -1,6 +1,9 @@
 import { CustomHead } from "@/next/components/Head";
 import { setupI18n } from "@/next/i18n";
-import { logStartupBanner } from "@/next/log-web";
+import {
+    logStartupBanner,
+    logUnhandledErrorsAndRejections,
+} from "@/next/log-web";
 import {
     APPS,
     APP_TITLES,
@@ -68,9 +71,11 @@ export default function App({ Component, pageProps }: AppProps) {
         setupI18n().finally(() => setIsI18nReady(true));
         const userId = (getData(LS_KEYS.USER) as User)?.id;
         logStartupBanner(APPS.AUTH, userId);
+        logUnhandledErrorsAndRejections(true);
         HTTPService.setHeaders({
             "X-Client-Package": CLIENT_PACKAGE_NAMES.get(APPS.AUTH),
         });
+        return () => logUnhandledErrorsAndRejections(false);
     }, []);
 
     const setUserOnline = () => setOffline(false);

+ 8 - 0
web/apps/cast/src/pages/_app.tsx

@@ -1,12 +1,20 @@
 import { CustomHead } from "@/next/components/Head";
+import { logUnhandledErrorsAndRejections } from "@/next/log-web";
 import { APPS, APP_TITLES } from "@ente/shared/apps/constants";
 import { getTheme } from "@ente/shared/themes";
 import { THEME_COLOR } from "@ente/shared/themes/constants";
 import { CssBaseline, ThemeProvider } from "@mui/material";
 import type { AppProps } from "next/app";
+import { useEffect } from "react";
+
 import "styles/global.css";
 
 export default function App({ Component, pageProps }: AppProps) {
+    useEffect(() => {
+        logUnhandledErrorsAndRejections(true);
+        return () => logUnhandledErrorsAndRejections(false);
+    }, []);
+
     return (
         <>
             <CustomHead title={APP_TITLES.get(APPS.PHOTOS)} />

+ 26 - 23
web/apps/photos/src/pages/_app.tsx

@@ -1,7 +1,10 @@
 import { CustomHead } from "@/next/components/Head";
 import { setupI18n } from "@/next/i18n";
 import log from "@/next/log";
-import { logStartupBanner } from "@/next/log-web";
+import {
+    logStartupBanner,
+    logUnhandledErrorsAndRejections,
+} from "@/next/log-web";
 import { AppUpdateInfo } from "@/next/types/ipc";
 import {
     APPS,
@@ -147,35 +150,35 @@ export default function App({ Component, pageProps }: AppProps) {
         setupI18n().finally(() => setIsI18nReady(true));
         const userId = (getData(LS_KEYS.USER) as User)?.id;
         logStartupBanner(APPS.PHOTOS, userId);
+        logUnhandledErrorsAndRejections(true);
         HTTPService.setHeaders({
             "X-Client-Package": CLIENT_PACKAGE_NAMES.get(APPS.PHOTOS),
         });
+        return () => logUnhandledErrorsAndRejections(false);
     }, []);
 
     useEffect(() => {
         const electron = globalThis.electron;
-        if (electron) {
-            const showUpdateDialog = (updateInfo: AppUpdateInfo) => {
-                if (updateInfo.autoUpdatable) {
-                    setDialogMessage(
-                        getUpdateReadyToInstallMessage(updateInfo),
-                    );
-                } else {
-                    setNotificationAttributes({
-                        endIcon: <ArrowForward />,
-                        variant: "secondary",
-                        message: t("UPDATE_AVAILABLE"),
-                        onClick: () =>
-                            setDialogMessage(
-                                getUpdateAvailableForDownloadMessage(
-                                    updateInfo,
-                                ),
-                            ),
-                    });
-                }
-            };
-            electron.registerUpdateEventListener(showUpdateDialog);
-        }
+        if (!electron) return;
+
+        const showUpdateDialog = (updateInfo: AppUpdateInfo) => {
+            if (updateInfo.autoUpdatable) {
+                setDialogMessage(getUpdateReadyToInstallMessage(updateInfo));
+            } else {
+                setNotificationAttributes({
+                    endIcon: <ArrowForward />,
+                    variant: "secondary",
+                    message: t("UPDATE_AVAILABLE"),
+                    onClick: () =>
+                        setDialogMessage(
+                            getUpdateAvailableForDownloadMessage(updateInfo),
+                        ),
+                });
+            }
+        };
+        electron.onAppUpdateAvailable(showUpdateDialog);
+
+        return () => electron.onAppUpdateAvailable(undefined);
     }, []);
 
     useEffect(() => {

+ 2 - 4
web/apps/photos/src/pages/gallery/index.tsx

@@ -363,16 +363,14 @@ export default function Gallery() {
             }, SYNC_INTERVAL_IN_MICROSECONDS);
             if (electron) {
                 void clipService.setupOnFileUploadListener();
-                electron.registerForegroundEventListener(() => {
-                    syncWithRemote(false, true);
-                });
+                electron.onMainWindowFocus(() => syncWithRemote(false, true));
             }
         };
         main();
         return () => {
             clearInterval(syncInterval.current);
             if (electron) {
-                electron.registerForegroundEventListener(() => {});
+                electron.onMainWindowFocus(undefined);
                 clipService.removeOnFileUploadListener();
             }
         };

+ 2 - 2
web/apps/photos/src/pages/index.tsx

@@ -133,9 +133,9 @@ export default function LandingPage() {
         const electron = globalThis.electron;
         if (!key && electron) {
             try {
-                key = await electron.getEncryptionKey();
+                key = await electron.encryptionKey();
             } catch (e) {
-                log.error("getEncryptionKey failed", e);
+                log.error("Failed to get encryption key from electron", e);
             }
             if (key) {
                 await saveKeyInSessionStore(

+ 10 - 10
web/apps/photos/src/utils/ui/index.tsx

@@ -1,3 +1,4 @@
+import { ensureElectron } from "@/next/electron";
 import { AppUpdateInfo } from "@/next/types/ipc";
 import { logoutUser } from "@ente/accounts/services/user";
 import { DialogBoxAttributes } from "@ente/shared/components/DialogBox/types";
@@ -52,35 +53,34 @@ export const getTrashFileMessage = (deleteFileHelper): DialogBoxAttributes => ({
     close: { text: t("CANCEL") },
 });
 
-export const getUpdateReadyToInstallMessage = (
-    updateInfo: AppUpdateInfo,
-): DialogBoxAttributes => ({
+export const getUpdateReadyToInstallMessage = ({
+    version,
+}: AppUpdateInfo): DialogBoxAttributes => ({
     icon: <AutoAwesomeOutlinedIcon />,
     title: t("UPDATE_AVAILABLE"),
     content: t("UPDATE_INSTALLABLE_MESSAGE"),
     proceed: {
-        action: () => globalThis.electron?.updateAndRestart(),
+        action: () => ensureElectron().updateAndRestart(),
         text: t("INSTALL_NOW"),
         variant: "accent",
     },
     close: {
         text: t("INSTALL_ON_NEXT_LAUNCH"),
         variant: "secondary",
-        action: () =>
-            globalThis.electron?.muteUpdateNotification(updateInfo.version),
+        action: () => ensureElectron().updateOnNextRestart(version),
     },
 });
 
-export const getUpdateAvailableForDownloadMessage = (
-    updateInfo: AppUpdateInfo,
-): DialogBoxAttributes => ({
+export const getUpdateAvailableForDownloadMessage = ({
+    version,
+}: AppUpdateInfo): DialogBoxAttributes => ({
     icon: <AutoAwesomeOutlinedIcon />,
     title: t("UPDATE_AVAILABLE"),
     content: t("UPDATE_AVAILABLE_MESSAGE"),
     close: {
         text: t("IGNORE_THIS_VERSION"),
         variant: "secondary",
-        action: () => globalThis.electron?.skipAppUpdate(updateInfo.version),
+        action: () => ensureElectron().skipAppUpdate(version),
     },
     proceed: {
         action: downloadApp,

+ 0 - 2
web/packages/accounts/api/user.ts

@@ -1,4 +1,3 @@
-import log from "@/next/log";
 import {
     RecoveryKey,
     TwoFactorRecoveryResponse,
@@ -62,7 +61,6 @@ export const _logout = async () => {
         ) {
             return;
         }
-        log.error("/users/logout failed", e);
         throw e;
     }
 };

+ 2 - 2
web/packages/accounts/pages/credentials.tsx

@@ -70,9 +70,9 @@ export default function Credentials({ appContext, appName }: PageProps) {
             const electron = globalThis.electron;
             if (!key && electron) {
                 try {
-                    key = await electron.getEncryptionKey();
+                    key = await electron.encryptionKey();
                 } catch (e) {
-                    log.error("getEncryptionKey failed", e);
+                    log.error("Failed to get encryption key from electron", e);
                 }
                 if (key) {
                     await saveKeyInSessionStore(

+ 39 - 44
web/packages/accounts/services/user.ts

@@ -11,49 +11,44 @@ import { PAGES } from "../constants/pages";
 
 export const logoutUser = async () => {
     try {
-        try {
-            await _logout();
-        } catch (e) {
-            // ignore
-        }
-        try {
-            InMemoryStore.clear();
-        } catch (e) {
-            // ignore
-            log.error("clear InMemoryStore failed", e);
-        }
-        try {
-            clearKeys();
-        } catch (e) {
-            log.error("clearKeys failed", e);
-        }
-        try {
-            clearData();
-        } catch (e) {
-            log.error("clearData failed", e);
-        }
-        try {
-            await deleteAllCache();
-        } catch (e) {
-            log.error("deleteAllCache failed", e);
-        }
-        try {
-            await clearFiles();
-        } catch (e) {
-            log.error("clearFiles failed", e);
-        }
-        try {
-            globalThis.electron?.clearElectronStore();
-        } catch (e) {
-            log.error("clearElectronStore failed", e);
-        }
-        try {
-            eventBus.emit(Events.LOGOUT);
-        } catch (e) {
-            log.error("Error in logout handlers", e);
-        }
-        router.push(PAGES.ROOT);
-    } catch (e) {
-        log.error("logoutUser failed", e);
+        await _logout();
+    } catch (e) {
+        log.error("Ignoring error during POST /users/logout", e);
+    }
+    try {
+        InMemoryStore.clear();
+    } catch (e) {
+        log.error("Ignoring error when clearing in-memory store", e);
+    }
+    try {
+        clearKeys();
+    } catch (e) {
+        log.error("Ignoring error when clearing keys", e);
+    }
+    try {
+        clearData();
+    } catch (e) {
+        log.error("Ignoring error when clearing data", e);
+    }
+    try {
+        await deleteAllCache();
+    } catch (e) {
+        log.error("Ignoring error when clearing caches", e);
+    }
+    try {
+        await clearFiles();
+    } catch (e) {
+        log.error("Ignoring error when clearing files", e);
+    }
+    try {
+        globalThis.electron?.clearStores();
+    } catch (e) {
+        log.error("Ignoring error when clearing electron stores", e);
+    }
+    try {
+        eventBus.emit(Events.LOGOUT);
+    } catch (e) {
+        log.error("Ignoring error in event-bus logout handlers", e);
     }
+    router.push(PAGES.ROOT);
 };

+ 27 - 0
web/packages/next/log-web.ts

@@ -18,6 +18,33 @@ export const logStartupBanner = (appId: string, userId?: number) => {
     log.info(`Starting ente-${appIdL}-web ${buildId}uid ${userId ?? 0}`);
 };
 
+/**
+ * Attach handlers to log any unhandled exceptions and promise rejections.
+ *
+ * @param attach If true, attach handlers, and if false, remove them. This
+ * allows us to use this in a React hook that cleans up after itself.
+ */
+export const logUnhandledErrorsAndRejections = (attach: boolean) => {
+    const handleError = (event: ErrorEvent) => {
+        log.error("Unhandled error", event.error);
+    };
+
+    const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
+        log.error("Unhandled promise rejection", event.reason);
+    };
+
+    if (attach) {
+        window.addEventListener("error", handleError);
+        window.addEventListener("unhandledrejection", handleUnhandledRejection);
+    } else {
+        window.removeEventListener("error", handleError);
+        window.removeEventListener(
+            "unhandledrejection",
+            handleUnhandledRejection,
+        );
+    }
+};
+
 interface LogEntry {
     timestamp: number;
     logLine: string;

+ 81 - 28
web/packages/next/types/ipc.ts

@@ -37,9 +37,22 @@ export enum PICKED_UPLOAD_TYPE {
 export interface Electron {
     // - General
 
-    /** Return the version of the desktop app. */
+    /**
+     * Return the version of the desktop app.
+     *
+     * The return value is of the form `v1.2.3`.
+     */
     appVersion: () => Promise<string>;
 
+    /**
+     * Log the given {@link message} to the on-disk log file maintained by the
+     * desktop app.
+     *
+     * Note: Unlike the other functions exposed over the Electron bridge,
+     * logToDisk is fire-and-forget and does not return a promise.
+     */
+    logToDisk: (message: string) => void;
+
     /**
      * Open the given {@link dirPath} in the system's folder viewer.
      *
@@ -55,13 +68,75 @@ export interface Electron {
     openLogDirectory: () => Promise<void>;
 
     /**
-     * Log the given {@link message} to the on-disk log file maintained by the
-     * desktop app.
+     * Clear any stored data.
      *
-     * Note: Unlike the other functions exposed over the Electron bridge,
-     * logToDisk is fire-and-forget and does not return a promise.
+     * This is a coarse single shot cleanup, meant for use in clearing any
+     * Electron side state during logout.
      */
-    logToDisk: (message: string) => void;
+    clearStores: () => void;
+
+    /**
+     * Return the previously saved encryption key from persistent safe storage.
+     *
+     * If no such key is found, return `undefined`.
+     *
+     * @see {@link saveEncryptionKey}.
+     */
+    encryptionKey: () => Promise<string | undefined>;
+
+    /**
+     * Save the given {@link encryptionKey} into persistent safe storage.
+     */
+    saveEncryptionKey: (encryptionKey: string) => Promise<void>;
+
+    /**
+     * Set or clear the callback {@link cb} to invoke whenever the app comes
+     * into the foreground. More precisely, the callback gets invoked when the
+     * main window gets focus.
+     *
+     * Note: Setting a callback clears any previous callbacks.
+     *
+     * @param cb The function to call when the main window gets focus. Pass
+     * `undefined` to clear the callback.
+     */
+    onMainWindowFocus: (cb?: () => void) => void;
+
+    // - App update
+
+    /**
+     * Set or clear the callback {@link cb} to invoke whenever a new
+     * (actionable) app update is available. This allows the Node.js layer to
+     * ask the renderer to show an "Update available" dialog to the user.
+     *
+     * Note: Setting a callback clears any previous callbacks.
+     */
+    onAppUpdateAvailable: (
+        cb?: ((updateInfo: AppUpdateInfo) => void) | undefined,
+    ) => void;
+
+    /**
+     * Restart the app to apply the latest available update.
+     *
+     * This is expected to be called in response to {@link onAppUpdateAvailable}
+     * if the user so wishes.
+     */
+    updateAndRestart: () => void;
+
+    /**
+     * Mute update notifications for the given {@link version}. This allows us
+     * to implement the "Install on next launch" functionality in response to
+     * the {@link onAppUpdateAvailable} event.
+     */
+    updateOnNextRestart: (version: string) => void;
+
+    /**
+     * Skip the app update with the given {@link version}.
+     *
+     * This is expected to be called in response to {@link onAppUpdateAvailable}
+     * if the user so wishes. It will remember this {@link version} as having
+     * been marked as skipped so that we don't prompt the user again.
+     */
+    skipAppUpdate: (version: string) => void;
 
     /**
      * A subset of filesystem access APIs.
@@ -98,28 +173,6 @@ export interface Electron {
      * the dataflow.
      */
 
-    // - General
-
-    registerForegroundEventListener: (onForeground: () => void) => void;
-
-    clearElectronStore: () => void;
-
-    setEncryptionKey: (encryptionKey: string) => Promise<void>;
-
-    getEncryptionKey: () => Promise<string>;
-
-    // - App update
-
-    updateAndRestart: () => void;
-
-    skipAppUpdate: (version: string) => void;
-
-    muteUpdateNotification: (version: string) => void;
-
-    registerUpdateEventListener: (
-        showUpdateDialog: (updateInfo: AppUpdateInfo) => void,
-    ) => void;
-
     // - Conversion
 
     convertToJPEG: (

+ 1 - 1
web/packages/shared/crypto/helpers.ts

@@ -103,7 +103,7 @@ export const saveKeyInSessionStore = async (
     setKey(keyType, sessionKeyAttributes);
     const electron = globalThis.electron;
     if (electron && !fromDesktop && keyType === SESSION_KEYS.ENCRYPTION_KEY) {
-        electron.setEncryptionKey(key);
+        electron.saveEncryptionKey(key);
     }
 };