diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 246b6da1b..b1e89b40c 100644 --- a/desktop/src/main.ts +++ b/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"), ); }; diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index f4da569c5..ecb3e2010 100644 --- a/desktop/src/main/ipc.ts +++ b/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("updateAndRestart", () => updateAndRestart()); - ipcMain.on("skip-app-update", (_, version) => skipAppUpdate(version)); - - 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) => diff --git a/desktop/src/main/log.ts b/desktop/src/main/log.ts index 04ecb6ea3..d43161fea 100644 --- a/desktop/src/main/log.ts +++ b/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; + }, + }); }; /** diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index 658932961..c6ac1688a 100644 --- a/desktop/src/main/menu.ts +++ b/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( diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index cb718f950..07736502b 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -52,58 +52,55 @@ import type { const appVersion = (): Promise => ipcRenderer.invoke("appVersion"); +const logToDisk = (message: string): void => + ipcRenderer.send("logToDisk", message); + const openDirectory = (dirPath: string): Promise => ipcRenderer.invoke("openDirectory", dirPath); const openLogDirectory = (): Promise => ipcRenderer.invoke("openLogDirectory"); -const logToDisk = (message: string): void => - ipcRenderer.send("logToDisk", message); +const clearStores = () => ipcRenderer.send("clearStores"); + +const encryptionKey = (): Promise => + ipcRenderer.invoke("encryptionKey"); + +const saveEncryptionKey = (encryptionKey: string): Promise => + ipcRenderer.invoke("saveEncryptionKey", encryptionKey); + +const onMainWindowFocus = (cb?: () => void) => { + ipcRenderer.removeAllListeners("mainWindowFocus"); + if (cb) ipcRenderer.on("mainWindowFocus", cb); +}; + +// - App update + +const onAppUpdateAvailable = ( + cb?: ((updateInfo: AppUpdateInfo) => void) | undefined, +) => { + ipcRenderer.removeAllListeners("appUpdateAvailable"); + if (cb) { + ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) => + cb(updateInfo), + ); + } +}; + +const updateAndRestart = () => ipcRenderer.send("updateAndRestart"); + +const updateOnNextRestart = (version: string) => + ipcRenderer.send("updateOnNextRestart", version); + +const skipAppUpdate = (version: string) => { + ipcRenderer.send("skipAppUpdate", version); +}; const fsExists = (path: string): Promise => ipcRenderer.invoke("fsExists", path); // - AUDIT below this -const registerForegroundEventListener = (onForeground: () => void) => { - ipcRenderer.removeAllListeners("app-in-foreground"); - ipcRenderer.on("app-in-foreground", onForeground); -}; - -const clearElectronStore = () => { - ipcRenderer.send("clear-electron-store"); -}; - -const setEncryptionKey = (encryptionKey: string): Promise => - ipcRenderer.invoke("setEncryptionKey", encryptionKey); - -const getEncryptionKey = (): Promise => - ipcRenderer.invoke("getEncryptionKey"); - -// - App update - -const registerUpdateEventListener = ( - showUpdateDialog: (updateInfo: AppUpdateInfo) => void, -) => { - ipcRenderer.removeAllListeners("show-update-dialog"); - ipcRenderer.on("show-update-dialog", (_, updateInfo: AppUpdateInfo) => { - showUpdateDialog(updateInfo); - }); -}; - -const updateAndRestart = () => { - ipcRenderer.send("update-and-restart"); -}; - -const skipAppUpdate = (version: string) => { - ipcRenderer.send("skip-app-update", version); -}; - -const muteUpdateNotification = (version: string) => { - ipcRenderer.send("mute-update-notification", version); -}; - // - Conversion const convertToJPEG = ( @@ -303,21 +300,19 @@ const getDirFiles = (dirPath: string): Promise => contextBridge.exposeInMainWorld("electron", { // - General appVersion, - openDirectory, - registerForegroundEventListener, - clearElectronStore, - getEncryptionKey, - setEncryptionKey, - - // - Logging - openLogDirectory, logToDisk, + openDirectory, + openLogDirectory, + clearStores, + encryptionKey, + saveEncryptionKey, + onMainWindowFocus, // - App update + onAppUpdateAvailable, updateAndRestart, + updateOnNextRestart, skipAppUpdate, - muteUpdateNotification, - registerUpdateEventListener, // - Conversion convertToJPEG, diff --git a/desktop/src/services/app-update.ts b/desktop/src/services/app-update.ts new file mode 100644 index 000000000..ec592095e --- /dev/null +++ b/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); diff --git a/desktop/src/services/appUpdater.ts b/desktop/src/services/appUpdater.ts deleted file mode 100644 index 517fc98e9..000000000 --- a/desktop/src/services/appUpdater.ts +++ /dev/null @@ -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); -} diff --git a/desktop/src/services/clip-service.ts b/desktop/src/services/clip.ts similarity index 100% rename from desktop/src/services/clip-service.ts rename to desktop/src/services/clip.ts diff --git a/desktop/src/services/store.ts b/desktop/src/services/store.ts index 20326dee1..a484080f5 100644 --- a/desktop/src/services/store.ts +++ b/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 { +export const encryptionKey = async (): Promise => { 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); +}; diff --git a/desktop/src/services/userPreference.ts b/desktop/src/services/userPreference.ts index 8074ee4de..c20657aa9 100644 --- a/desktop/src/services/userPreference.ts +++ b/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"); -} diff --git a/desktop/src/stores/userPreferences.store.ts b/desktop/src/stores/user-preferences.ts similarity index 63% rename from desktop/src/stores/userPreferences.store.ts rename to desktop/src/stores/user-preferences.ts index 9545b1261..396e7a86c 100644 --- a/desktop/src/stores/userPreferences.store.ts +++ b/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 = { +interface UserPreferencesSchema { + hideDockIcon: boolean; + skipAppVersion?: string; + muteUpdateNotificationVersion?: string; +} + +const userPreferencesSchema: Schema = { hideDockIcon: { type: "boolean", }, diff --git a/desktop/src/types/main.ts b/desktop/src/types/main.ts index c875db1ab..546749c54 100644 --- a/desktop/src/types/main.ts +++ b/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; -} diff --git a/web/apps/accounts/src/pages/_app.tsx b/web/apps/accounts/src/pages/_app.tsx index 2aed14e48..40a4a1458 100644 --- a/web/apps/accounts/src/pages/_app.tsx +++ b/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 = () => { diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index bd59ac225..bf1093c90 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/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); diff --git a/web/apps/cast/src/pages/_app.tsx b/web/apps/cast/src/pages/_app.tsx index 3f22f687c..99b047d41 100644 --- a/web/apps/cast/src/pages/_app.tsx +++ b/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 ( <> diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 4594b2e20..06961d6c9 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/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: , - 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: , + variant: "secondary", + message: t("UPDATE_AVAILABLE"), + onClick: () => + setDialogMessage( + getUpdateAvailableForDownloadMessage(updateInfo), + ), + }); + } + }; + electron.onAppUpdateAvailable(showUpdateDialog); + + return () => electron.onAppUpdateAvailable(undefined); }, []); useEffect(() => { diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index b772771c4..bdbccedfb 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/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(); } }; diff --git a/web/apps/photos/src/pages/index.tsx b/web/apps/photos/src/pages/index.tsx index f29020086..760549bdb 100644 --- a/web/apps/photos/src/pages/index.tsx +++ b/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( diff --git a/web/apps/photos/src/utils/ui/index.tsx b/web/apps/photos/src/utils/ui/index.tsx index 9090c6917..1b01116d3 100644 --- a/web/apps/photos/src/utils/ui/index.tsx +++ b/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: , 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: , 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, diff --git a/web/packages/accounts/api/user.ts b/web/packages/accounts/api/user.ts index 865a0c217..7a072064e 100644 --- a/web/packages/accounts/api/user.ts +++ b/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; } }; diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index bf5943238..3e8fbabbe 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/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( diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 43d2f0883..87a320e36 100644 --- a/web/packages/accounts/services/user.ts +++ b/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); + await _logout(); } catch (e) { - log.error("logoutUser failed", 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); }; diff --git a/web/packages/next/log-web.ts b/web/packages/next/log-web.ts index 093a2065c..f319118ce 100644 --- a/web/packages/next/log-web.ts +++ b/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; diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index 8451b045e..a0bc07d9a 100644 --- a/web/packages/next/types/ipc.ts +++ b/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; + /** + * 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; /** - * 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; + + /** + * Save the given {@link encryptionKey} into persistent safe storage. + */ + saveEncryptionKey: (encryptionKey: string) => Promise; + + /** + * 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; - - getEncryptionKey: () => Promise; - - // - App update - - updateAndRestart: () => void; - - skipAppUpdate: (version: string) => void; - - muteUpdateNotification: (version: string) => void; - - registerUpdateEventListener: ( - showUpdateDialog: (updateInfo: AppUpdateInfo) => void, - ) => void; - // - Conversion convertToJPEG: ( diff --git a/web/packages/shared/crypto/helpers.ts b/web/packages/shared/crypto/helpers.ts index 9250b4ab7..89fc27840 100644 --- a/web/packages/shared/crypto/helpers.ts +++ b/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); } };