Selaa lähdekoodia

[web] Systematize logout (#1729)

+ ML cleanup
Manav Rathi 1 vuosi sitten
vanhempi
commit
69460418ef
40 muutettua tiedostoa jossa 312 lisäystä ja 338 poistoa
  1. 10 2
      desktop/src/main.ts
  2. 9 12
      desktop/src/main/ipc.ts
  3. 30 0
      desktop/src/main/services/logout.ts
  4. 9 0
      desktop/src/main/services/watch.ts
  5. 6 9
      desktop/src/preload.ts
  6. 8 0
      web/apps/accounts/src/pages/_app.tsx
  7. 2 3
      web/apps/auth/src/components/Navbar.tsx
  8. 7 0
      web/apps/auth/src/pages/_app.tsx
  9. 3 3
      web/apps/photos/src/components/DeleteAccountModal.tsx
  10. 4 5
      web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx
  11. 4 6
      web/apps/photos/src/components/Sidebar/ExitSection.tsx
  12. 9 17
      web/apps/photos/src/pages/_app.tsx
  13. 23 8
      web/apps/photos/src/pages/gallery/index.tsx
  14. 1 2
      web/apps/photos/src/pages/shared-albums/index.tsx
  15. 2 3
      web/apps/photos/src/services/clip-service.ts
  16. 9 17
      web/apps/photos/src/services/download/index.ts
  17. 50 0
      web/apps/photos/src/services/logout.ts
  18. 2 2
      web/apps/photos/src/services/machineLearning/faceService.ts
  19. 0 24
      web/apps/photos/src/services/machineLearning/machineLearningService.ts
  20. 19 49
      web/apps/photos/src/services/machineLearning/mlWorkManager.ts
  21. 2 2
      web/apps/photos/src/services/machineLearning/peopleService.ts
  22. 8 25
      web/apps/photos/src/services/machineLearning/readerService.ts
  23. 7 3
      web/apps/photos/src/services/ml/db.ts
  24. 8 0
      web/apps/photos/src/services/ml/face.ts
  25. 0 0
      web/apps/photos/src/services/ml/face.worker.ts
  26. 0 20
      web/apps/photos/src/services/userService.ts
  27. 0 13
      web/apps/photos/src/utils/comlink/ComlinkMLWorker.ts
  28. 4 3
      web/apps/photos/src/utils/ui/index.tsx
  29. 1 1
      web/packages/accounts/api/user.ts
  30. 3 2
      web/packages/accounts/pages/credentials.tsx
  31. 3 2
      web/packages/accounts/pages/generate.tsx
  32. 3 2
      web/packages/accounts/pages/two-factor/recover.tsx
  33. 8 4
      web/packages/accounts/pages/two-factor/verify.tsx
  34. 5 4
      web/packages/accounts/pages/verify.tsx
  35. 50 0
      web/packages/accounts/services/logout.ts
  36. 0 67
      web/packages/accounts/services/user.ts
  37. 2 23
      web/packages/next/types/ipc.ts
  38. 1 0
      web/packages/shared/apps/types.ts
  39. 0 0
      web/packages/shared/storage/localForage.ts
  40. 0 5
      web/packages/shared/storage/localForage/helpers.ts

+ 10 - 2
desktop/src/main.ts

@@ -17,7 +17,11 @@ import { existsSync } from "node:fs";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
-import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
+import {
+    attachFSWatchIPCHandlers,
+    attachIPCHandlers,
+    attachLogoutIPCHandler,
+} from "./main/ipc";
 import log, { initLogging } from "./main/log";
 import { createApplicationMenu, createTrayContextMenu } from "./main/menu";
 import { setupAutoUpdater } from "./main/services/app-update";
@@ -377,8 +381,12 @@ const main = () => {
         void (async () => {
             // Create window and prepare for the renderer.
             mainWindow = createMainWindow();
+
+            // Setup IPC and streams.
+            const watcher = createWatcher(mainWindow);
             attachIPCHandlers();
-            attachFSWatchIPCHandlers(createWatcher(mainWindow));
+            attachFSWatchIPCHandlers(watcher);
+            attachLogoutIPCHandler(watcher);
             registerStreamProtocol();
 
             // Configure the renderer's environment.

+ 9 - 12
desktop/src/main/ipc.ts

@@ -41,16 +41,13 @@ import {
     fsWriteFile,
 } from "./services/fs";
 import { convertToJPEG, generateImageThumbnail } from "./services/image";
+import { logout } from "./services/logout";
 import {
     clipImageEmbedding,
     clipTextEmbeddingIfAvailable,
 } from "./services/ml-clip";
 import { detectFaces, faceEmbedding } from "./services/ml-face";
-import {
-    clearStores,
-    encryptionKey,
-    saveEncryptionKey,
-} from "./services/store";
+import { encryptionKey, saveEncryptionKey } from "./services/store";
 import {
     clearPendingUploads,
     listZipItems,
@@ -65,11 +62,9 @@ import {
     watchFindFiles,
     watchGet,
     watchRemove,
-    watchReset,
     watchUpdateIgnoredFiles,
     watchUpdateSyncedFiles,
 } from "./services/watch";
-import { clearConvertToMP4Results } from "./stream";
 
 /**
  * Listen for IPC events sent/invoked by the renderer process, and route them to
@@ -107,10 +102,6 @@ export const attachIPCHandlers = () => {
 
     ipcMain.handle("selectDirectory", () => selectDirectory());
 
-    ipcMain.on("clearStores", () => clearStores());
-
-    ipcMain.on("clearConvertToMP4Results", () => clearConvertToMP4Results());
-
     ipcMain.handle("saveEncryptionKey", (_, encryptionKey: string) =>
         saveEncryptionKey(encryptionKey),
     );
@@ -265,6 +256,12 @@ export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
     ipcMain.handle("watchFindFiles", (_, folderPath: string) =>
         watchFindFiles(folderPath),
     );
+};
 
-    ipcMain.handle("watchReset", () => watchReset(watcher));
+/**
+ * Sibling of {@link attachIPCHandlers} specifically for use with the logout
+ * event with needs access to the {@link FSWatcher} instance.
+ */
+export const attachLogoutIPCHandler = (watcher: FSWatcher) => {
+    ipcMain.handle("logout", () => logout(watcher));
 };

+ 30 - 0
desktop/src/main/services/logout.ts

@@ -0,0 +1,30 @@
+import type { FSWatcher } from "chokidar";
+import log from "../log";
+import { clearConvertToMP4Results } from "../stream";
+import { clearStores } from "./store";
+import { watchReset } from "./watch";
+
+/**
+ * Perform the native side logout sequence.
+ *
+ * This function is guaranteed not to throw any errors.
+ *
+ * See: [Note: Do not throw during logout].
+ */
+export const logout = (watcher: FSWatcher) => {
+    try {
+        watchReset(watcher);
+    } catch (e) {
+        log.error("Ignoring error during logout (FS watch)", e);
+    }
+    try {
+        clearConvertToMP4Results();
+    } catch (e) {
+        log.error("Ignoring error during logout (convert-to-mp4)", e);
+    }
+    try {
+        clearStores();
+    } catch (e) {
+        log.error("Ignoring error during logout (native stores)", e);
+    }
+};

+ 9 - 0
desktop/src/main/services/watch.ts

@@ -151,6 +151,15 @@ export const watchFindFiles = async (dirPath: string) => {
     return paths;
 };
 
+/**
+ * Stop watching all existing folder watches and remove any callbacks.
+ *
+ * This function is meant to be called when the user logs out. It stops
+ * all existing folder watches and forgets about any "on*" callback
+ * functions that have been registered.
+ *
+ * The persisted state itself gets cleared via {@link clearStores}.
+ */
 export const watchReset = (watcher: FSWatcher) => {
     watcher.unwatch(folderWatches().map((watch) => watch.folderPath));
 };

+ 6 - 9
desktop/src/preload.ts

@@ -63,10 +63,10 @@ const openLogDirectory = () => ipcRenderer.invoke("openLogDirectory");
 
 const selectDirectory = () => ipcRenderer.invoke("selectDirectory");
 
-const clearStores = () => ipcRenderer.send("clearStores");
-
-const clearConvertToMP4Results = () =>
-    ipcRenderer.send("clearConvertToMP4Results");
+const logout = () => {
+    watchRemoveListeners();
+    ipcRenderer.send("logout");
+};
 
 const encryptionKey = () => ipcRenderer.invoke("encryptionKey");
 
@@ -212,11 +212,10 @@ const watchOnRemoveDir = (f: (path: string, watch: FolderWatch) => void) => {
 const watchFindFiles = (folderPath: string) =>
     ipcRenderer.invoke("watchFindFiles", folderPath);
 
-const watchReset = async () => {
+const watchRemoveListeners = () => {
     ipcRenderer.removeAllListeners("watchAddFile");
     ipcRenderer.removeAllListeners("watchRemoveFile");
     ipcRenderer.removeAllListeners("watchRemoveDir");
-    await ipcRenderer.invoke("watchReset");
 };
 
 // - Upload
@@ -308,8 +307,7 @@ contextBridge.exposeInMainWorld("electron", {
     openDirectory,
     openLogDirectory,
     selectDirectory,
-    clearStores,
-    clearConvertToMP4Results,
+    logout,
     encryptionKey,
     saveEncryptionKey,
     onMainWindowFocus,
@@ -360,7 +358,6 @@ contextBridge.exposeInMainWorld("electron", {
         onRemoveFile: watchOnRemoveFile,
         onRemoveDir: watchOnRemoveDir,
         findFiles: watchFindFiles,
-        reset: watchReset,
     },
 
     // - Upload

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

@@ -1,6 +1,8 @@
 import { CustomHead } from "@/next/components/Head";
 import { setupI18n } from "@/next/i18n";
 import { logUnhandledErrorsAndRejections } from "@/next/log-web";
+import { PAGES } from "@ente/accounts/constants/pages";
+import { accountLogout } from "@ente/accounts/services/logout";
 import { APPS, APP_TITLES } from "@ente/shared/apps/constants";
 import { Overlay } from "@ente/shared/components/Container";
 import DialogBoxV2 from "@ente/shared/components/DialogBoxV2";
@@ -27,6 +29,7 @@ interface AppContextProps {
     isMobile: boolean;
     showNavBar: (show: boolean) => void;
     setDialogBoxAttributesV2: SetDialogBoxAttributesV2;
+    logout: () => void;
 }
 
 export const AppContext = createContext<AppContextProps>({} as AppContextProps);
@@ -78,6 +81,10 @@ export default function App({ Component, pageProps }: AppProps) {
 
     const theme = getTheme(themeColor, APPS.PHOTOS);
 
+    const logout = () => {
+        void accountLogout().then(() => router.push(PAGES.ROOT));
+    };
+
     const title = isI18nReady
         ? t("TITLE", { context: APPS.ACCOUNTS })
         : APP_TITLES.get(APPS.ACCOUNTS);
@@ -101,6 +108,7 @@ export default function App({ Component, pageProps }: AppProps) {
                         showNavBar,
                         setDialogBoxAttributesV2:
                             setDialogBoxAttributesV2 as any,
+                        logout,
                     }}
                 >
                     {!isI18nReady && (

+ 2 - 3
web/apps/auth/src/components/Navbar.tsx

@@ -1,4 +1,3 @@
-import { logoutUser } from "@ente/accounts/services/user";
 import { HorizontalFlex } from "@ente/shared/components/Container";
 import { EnteLogo } from "@ente/shared/components/EnteLogo";
 import NavbarBase from "@ente/shared/components/Navbar/base";
@@ -11,7 +10,7 @@ import { AppContext } from "pages/_app";
 import React from "react";
 
 export default function AuthNavbar() {
-    const { isMobile } = React.useContext(AppContext);
+    const { isMobile, logout } = React.useContext(AppContext);
     return (
         <NavbarBase isMobile={isMobile}>
             <HorizontalFlex flex={1} justifyContent={"center"}>
@@ -25,7 +24,7 @@ export default function AuthNavbar() {
                     <OverflowMenuOption
                         color="critical"
                         startIcon={<LogoutOutlined />}
-                        onClick={logoutUser}
+                        onClick={logout}
                     >
                         {t("LOGOUT")}
                     </OverflowMenuOption>

+ 7 - 0
web/apps/auth/src/pages/_app.tsx

@@ -4,6 +4,7 @@ import {
     logStartupBanner,
     logUnhandledErrorsAndRejections,
 } from "@/next/log-web";
+import { accountLogout } from "@ente/accounts/services/logout";
 import {
     APPS,
     APP_TITLES,
@@ -44,6 +45,7 @@ type AppContextType = {
     setThemeColor: SetTheme;
     somethingWentWrong: () => void;
     setDialogBoxAttributesV2: SetDialogBoxAttributesV2;
+    logout: () => void;
 };
 
 export const AppContext = createContext<AppContextType>(null);
@@ -128,6 +130,10 @@ export default function App({ Component, pageProps }: AppProps) {
             content: t("UNKNOWN_ERROR"),
         });
 
+    const logout = () => {
+        void accountLogout().then(() => router.push(PAGES.ROOT));
+    };
+
     const title = isI18nReady
         ? t("TITLE", { context: APPS.AUTH })
         : APP_TITLES.get(APPS.AUTH);
@@ -162,6 +168,7 @@ export default function App({ Component, pageProps }: AppProps) {
                         setThemeColor,
                         somethingWentWrong,
                         setDialogBoxAttributesV2,
+                        logout,
                     }}
                 >
                     {(loading || !isI18nReady) && (

+ 3 - 3
web/apps/photos/src/components/DeleteAccountModal.tsx

@@ -1,5 +1,4 @@
 import log from "@/next/log";
-import { logoutUser } from "@ente/accounts/services/user";
 import DialogBoxV2 from "@ente/shared/components/DialogBoxV2";
 import EnteButton from "@ente/shared/components/EnteButton";
 import { DELETE_ACCOUNT_EMAIL } from "@ente/shared/constants/urls";
@@ -43,7 +42,8 @@ const getReasonOptions = (): DropdownOption<DELETE_REASON>[] => {
 };
 
 const DeleteAccountModal = ({ open, onClose }: Iprops) => {
-    const { setDialogBoxAttributesV2, isMobile } = useContext(AppContext);
+    const { setDialogBoxAttributesV2, isMobile, logout } =
+        useContext(AppContext);
     const { authenticateUser } = useContext(GalleryContext);
     const [loading, setLoading] = useState(false);
     const deleteAccountChallenge = useRef<string>();
@@ -145,7 +145,7 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => {
             );
             const { reason, feedback } = reasonAndFeedbackRef.current;
             await deleteAccount(decryptedChallenge, reason, feedback);
-            logoutUser();
+            logout();
         } catch (e) {
             log.error("solveChallengeAndDeleteAccount failed", e);
             somethingWentWrong();

+ 4 - 5
web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx

@@ -1,18 +1,17 @@
+import { VerticallyCenteredFlex } from "@ente/shared/components/Container";
 import ChevronRight from "@mui/icons-material/ChevronRight";
 import ScienceIcon from "@mui/icons-material/Science";
 import { Box, DialogProps, Stack, Typography } from "@mui/material";
 import { EnteDrawer } from "components/EnteDrawer";
+import { EnteMenuItem } from "components/Menu/EnteMenuItem";
+import { MenuItemGroup } from "components/Menu/MenuItemGroup";
 import MenuSectionTitle from "components/Menu/MenuSectionTitle";
 import Titlebar from "components/Titlebar";
 import { MLSearchSettings } from "components/ml/MLSearchSettings";
 import { t } from "i18next";
-import { useContext, useEffect, useState } from "react";
-
-import { VerticallyCenteredFlex } from "@ente/shared/components/Container";
-import { EnteMenuItem } from "components/Menu/EnteMenuItem";
-import { MenuItemGroup } from "components/Menu/MenuItemGroup";
 import isElectron from "is-electron";
 import { AppContext } from "pages/_app";
+import { useContext, useEffect, useState } from "react";
 import { CLIPIndexingStatus, clipService } from "services/clip-service";
 import { formatNumber } from "utils/number/format";
 

+ 4 - 6
web/apps/photos/src/components/Sidebar/ExitSection.tsx

@@ -1,13 +1,11 @@
-import { t } from "i18next";
-import { useContext, useState } from "react";
-
-import { logoutUser } from "@ente/accounts/services/user";
 import DeleteAccountModal from "components/DeleteAccountModal";
 import { EnteMenuItem } from "components/Menu/EnteMenuItem";
+import { t } from "i18next";
 import { AppContext } from "pages/_app";
+import { useContext, useState } from "react";
 
 export default function ExitSection() {
-    const { setDialogMessage } = useContext(AppContext);
+    const { setDialogMessage, logout } = useContext(AppContext);
 
     const [deleteAccountModalView, setDeleteAccountModalView] = useState(false);
 
@@ -19,7 +17,7 @@ export default function ExitSection() {
             title: t("LOGOUT_MESSAGE"),
             proceed: {
                 text: t("LOGOUT"),
-                action: logoutUser,
+                action: logout,
                 variant: "critical",
             },
             close: { text: t("CANCEL") },

+ 9 - 17
web/apps/photos/src/pages/_app.tsx

@@ -26,7 +26,6 @@ import EnteSpinner from "@ente/shared/components/EnteSpinner";
 import { MessageContainer } from "@ente/shared/components/MessageContainer";
 import AppNavbar from "@ente/shared/components/Navbar/app";
 import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages";
-import { Events, eventBus } from "@ente/shared/events";
 import { useLocalState } from "@ente/shared/hooks/useLocalState";
 import HTTPService from "@ente/shared/network/HTTPService";
 import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
@@ -52,7 +51,8 @@ import "photoswipe/dist/photoswipe.css";
 import { createContext, useEffect, useRef, useState } from "react";
 import LoadingBar from "react-top-loading-bar";
 import DownloadManager from "services/download";
-import exportService, { resumeExportsIfNeeded } from "services/export";
+import { resumeExportsIfNeeded } from "services/export";
+import { photosLogout } from "services/logout";
 import {
     getMLSearchConfig,
     updateMLSearchConfig,
@@ -100,6 +100,7 @@ type AppContextType = {
     setDialogBoxAttributesV2: SetDialogBoxAttributesV2;
     isCFProxyDisabled: boolean;
     setIsCFProxyDisabled: (disabled: boolean) => void;
+    logout: () => void;
 };
 
 export const AppContext = createContext<AppContextType>(null);
@@ -188,14 +189,6 @@ export default function App({ Component, pageProps }: AppProps) {
             }
         };
         loadMlSearchState();
-        try {
-            eventBus.on(Events.LOGOUT, () => {
-                setMlSearchEnabled(false);
-                mlWorkManager.setMlSearchEnabled(false);
-            });
-        } catch (e) {
-            log.error("Error while subscribing to logout event", e);
-        }
     }, []);
 
     useEffect(() => {
@@ -213,13 +206,6 @@ export default function App({ Component, pageProps }: AppProps) {
             await resumeExportsIfNeeded();
         };
         initExport();
-        try {
-            eventBus.on(Events.LOGOUT, () => {
-                exportService.disableContinuousExport();
-            });
-        } catch (e) {
-            log.error("Error while subscribing to logout event", e);
-        }
     }, []);
 
     const setUserOnline = () => setOffline(false);
@@ -336,6 +322,11 @@ export default function App({ Component, pageProps }: AppProps) {
             content: t("UNKNOWN_ERROR"),
         });
 
+    const logout = () => {
+        setMlSearchEnabled(false);
+        void photosLogout().then(() => router.push(PAGES.ROOT));
+    };
+
     const title = isI18nReady
         ? t("TITLE", { context: APPS.PHOTOS })
         : APP_TITLES.get(APPS.PHOTOS);
@@ -394,6 +385,7 @@ export default function App({ Component, pageProps }: AppProps) {
                         updateMapEnabled,
                         isCFProxyDisabled,
                         setIsCFProxyDisabled,
+                        logout,
                     }}
                 >
                     {(loading || !isI18nReady) && (

+ 23 - 8
web/apps/photos/src/pages/gallery/index.tsx

@@ -3,6 +3,7 @@ import { APPS } from "@ente/shared/apps/constants";
 import { CenteredFlex } from "@ente/shared/components/Container";
 import EnteSpinner from "@ente/shared/components/EnteSpinner";
 import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages";
+import { getRecoveryKey } from "@ente/shared/crypto/helpers";
 import { CustomError } from "@ente/shared/error";
 import { useFileInput } from "@ente/shared/hooks/useFileInput";
 import useMemoSingleThreaded from "@ente/shared/hooks/useMemoSingleThreaded";
@@ -93,11 +94,7 @@ import { getLocalFiles, syncFiles } from "services/fileService";
 import locationSearchService from "services/locationSearchService";
 import { getLocalTrashedFiles, syncTrash } from "services/trashService";
 import uploadManager from "services/upload/uploadManager";
-import {
-    isTokenValid,
-    syncMapEnabled,
-    validateKey,
-} from "services/userService";
+import { isTokenValid, syncMapEnabled } from "services/userService";
 import { Collection, CollectionSummaries } from "types/collection";
 import { EnteFile } from "types/file";
 import {
@@ -249,8 +246,13 @@ export default function Gallery() {
     const [tempHiddenFileIds, setTempHiddenFileIds] = useState<Set<number>>(
         new Set<number>(),
     );
-    const { startLoading, finishLoading, setDialogMessage, ...appContext } =
-        useContext(AppContext);
+    const {
+        startLoading,
+        finishLoading,
+        setDialogMessage,
+        logout,
+        ...appContext
+    } = useContext(AppContext);
     const [collectionSummaries, setCollectionSummaries] =
         useState<CollectionSummaries>();
     const [hiddenCollectionSummaries, setHiddenCollectionSummaries] =
@@ -319,6 +321,19 @@ export default function Gallery() {
     const [isClipSearchResult, setIsClipSearchResult] =
         useState<boolean>(false);
 
+    // Ensure that the keys in local storage are not malformed by verifying that
+    // the recoveryKey can be decrypted with the masterKey.
+    // Note: This is not bullet-proof.
+    const validateKey = async () => {
+        try {
+            await getRecoveryKey();
+            return true;
+        } catch (e) {
+            logout();
+            return false;
+        }
+    };
+
     useEffect(() => {
         appContext.showNavBar(true);
         const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
@@ -672,7 +687,7 @@ export default function Gallery() {
     }, [collections, hiddenCollections]);
 
     const showSessionExpiredMessage = () => {
-        setDialogMessage(getSessionExpiredMessage());
+        setDialogMessage(getSessionExpiredMessage(logout));
     };
 
     const syncWithRemote = async (force = false, silent = false) => {

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

@@ -1,5 +1,4 @@
 import log from "@/next/log";
-import { logoutUser } from "@ente/accounts/services/user";
 import { APPS } from "@ente/shared/apps/constants";
 import {
     CenteredFlex,
@@ -185,7 +184,7 @@ export default function PublicCollectionGallery() {
             nonClosable: true,
             proceed: {
                 text: t("LOGIN"),
-                action: logoutUser,
+                action: () => router.push(PAGES.ROOT),
                 variant: "accent",
             },
         });

+ 2 - 3
web/apps/photos/src/services/clip-service.ts

@@ -80,21 +80,20 @@ class CLIPService {
         this.liveEmbeddingExtractionQueue = new PQueue({
             concurrency: 1,
         });
-        eventBus.on(Events.LOGOUT, this.logoutHandler, this);
     }
 
     isPlatformSupported = () => {
         return isElectron();
     };
 
-    private logoutHandler = async () => {
+    async logout() {
         if (this.embeddingExtractionInProgress) {
             this.embeddingExtractionInProgress.abort();
         }
         if (this.onFileUploadedHandler) {
             await this.removeOnFileUploadListener();
         }
-    };
+    }
 
     setupOnFileUploadListener = async () => {
         try {

+ 9 - 17
web/apps/photos/src/services/download/index.ts

@@ -6,7 +6,6 @@ import { APPS } from "@ente/shared/apps/constants";
 import ComlinkCryptoWorker from "@ente/shared/crypto";
 import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker";
 import { CustomError } from "@ente/shared/error";
-import { Events, eventBus } from "@ente/shared/events";
 import { isPlaybackPossible } from "@ente/shared/media/video-playback";
 import { Remote } from "comlink";
 import isElectron from "is-electron";
@@ -107,7 +106,6 @@ class DownloadManagerImpl {
         // }
         this.cryptoWorker = await ComlinkCryptoWorker.getInstance();
         this.ready = true;
-        eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this);
     }
 
     private ensureInitialized() {
@@ -117,21 +115,15 @@ class DownloadManagerImpl {
             );
     }
 
-    private async logoutHandler() {
-        try {
-            log.info("downloadManger logoutHandler started");
-            this.ready = false;
-            this.cryptoWorker = null;
-            this.downloadClient = null;
-            this.fileObjectURLPromises.clear();
-            this.fileConversionPromises.clear();
-            this.thumbnailObjectURLPromises.clear();
-            this.fileDownloadProgress.clear();
-            this.progressUpdater = () => {};
-            log.info("downloadManager logoutHandler completed");
-        } catch (e) {
-            log.error("downloadManager logoutHandler failed", e);
-        }
+    async logout() {
+        this.ready = false;
+        this.cryptoWorker = null;
+        this.downloadClient = null;
+        this.fileObjectURLPromises.clear();
+        this.fileConversionPromises.clear();
+        this.thumbnailObjectURLPromises.clear();
+        this.fileDownloadProgress.clear();
+        this.progressUpdater = () => {};
     }
 
     updateToken(token: string, passwordToken?: string) {

+ 50 - 0
web/apps/photos/src/services/logout.ts

@@ -0,0 +1,50 @@
+import log from "@/next/log";
+import { accountLogout } from "@ente/accounts/services/logout";
+import { clipService } from "services/clip-service";
+import DownloadManager from "./download";
+import exportService from "./export";
+import mlWorkManager from "./machineLearning/mlWorkManager";
+
+/**
+ * Logout sequence for the photos app.
+ *
+ * This function is guaranteed not to throw any errors.
+ *
+ * See: [Note: Do not throw during logout].
+ */
+export const photosLogout = async () => {
+    await accountLogout();
+
+    try {
+        await DownloadManager.logout();
+    } catch (e) {
+        log.error("Ignoring error during logout (download)", e);
+    }
+
+    try {
+        await clipService.logout();
+    } catch (e) {
+        log.error("Ignoring error during logout (CLIP)", e);
+    }
+
+    const electron = globalThis.electron;
+    if (electron) {
+        try {
+            await mlWorkManager.logout();
+        } catch (e) {
+            log.error("Ignoring error during logout (ML)", e);
+        }
+
+        try {
+            exportService.disableContinuousExport();
+        } catch (e) {
+            log.error("Ignoring error during logout (export)", e);
+        }
+
+        try {
+            await electron?.logout();
+        } catch (e) {
+            log.error("Ignoring error during logout (electron)", e);
+        }
+    }
+};

+ 2 - 2
web/apps/photos/src/services/machineLearning/faceService.ts

@@ -11,9 +11,9 @@ import {
 } from "services/ml/types";
 import { imageBitmapToBlob, warpAffineFloat32List } from "utils/image";
 import ReaderService, {
+    fetchImageBitmap,
     getFaceId,
     getLocalFile,
-    getOriginalImageBitmap,
 } from "./readerService";
 
 class FaceService {
@@ -296,7 +296,7 @@ class FaceService {
         }
 
         const file = await getLocalFile(personFace.fileId);
-        const imageBitmap = await getOriginalImageBitmap(file);
+        const imageBitmap = await fetchImageBitmap(file);
         return await this.saveFaceCrop(imageBitmap, personFace, syncContext);
     }
 }

+ 0 - 24
web/apps/photos/src/services/machineLearning/machineLearningService.ts

@@ -14,7 +14,6 @@ import { getLocalFiles } from "services/fileService";
 import mlIDbStorage, {
     ML_SEARCH_CONFIG_NAME,
     ML_SYNC_CONFIG_NAME,
-    ML_SYNC_JOB_CONFIG_NAME,
 } from "services/ml/db";
 import {
     BlurDetectionMethod,
@@ -48,19 +47,11 @@ import dbscanClusteringService from "./dbscanClusteringService";
 import FaceService from "./faceService";
 import hdbscanClusteringService from "./hdbscanClusteringService";
 import laplacianBlurDetectionService from "./laplacianBlurDetectionService";
-import type { JobConfig } from "./mlWorkManager";
 import mobileFaceNetEmbeddingService from "./mobileFaceNetEmbeddingService";
 import PeopleService from "./peopleService";
 import ReaderService from "./readerService";
 import yoloFaceDetectionService from "./yoloFaceDetectionService";
 
-export const DEFAULT_ML_SYNC_JOB_CONFIG: JobConfig = {
-    intervalSec: 5,
-    // TODO: finalize this after seeing effects on and from machine sleep
-    maxItervalSec: 960,
-    backoffMultiplier: 2,
-};
-
 export const DEFAULT_ML_SYNC_CONFIG: MLSyncConfig = {
     batchSize: 200,
     imageSource: "Original",
@@ -108,13 +99,6 @@ export const DEFAULT_ML_SEARCH_CONFIG: MLSearchConfig = {
 
 export const MAX_ML_SYNC_ERROR_COUNT = 1;
 
-export async function getMLSyncJobConfig() {
-    return mlIDbStorage.getConfig(
-        ML_SYNC_JOB_CONFIG_NAME,
-        DEFAULT_ML_SYNC_JOB_CONFIG,
-    );
-}
-
 export async function getMLSyncConfig() {
     return mlIDbStorage.getConfig(ML_SYNC_CONFIG_NAME, DEFAULT_ML_SYNC_CONFIG);
 }
@@ -131,14 +115,6 @@ export async function getMLSearchConfig() {
     return DEFAULT_ML_SEARCH_CONFIG;
 }
 
-export async function updateMLSyncJobConfig(newConfig: JobConfig) {
-    return mlIDbStorage.putConfig(ML_SYNC_JOB_CONFIG_NAME, newConfig);
-}
-
-export async function updateMLSyncConfig(newConfig: MLSyncConfig) {
-    return mlIDbStorage.putConfig(ML_SYNC_CONFIG_NAME, newConfig);
-}
-
 export async function updateMLSearchConfig(newConfig: MLSearchConfig) {
     return mlIDbStorage.putConfig(ML_SEARCH_CONFIG_NAME, newConfig);
 }

+ 19 - 49
web/apps/photos/src/services/machineLearning/mlWorkManager.ts

@@ -5,12 +5,11 @@ import { eventBus, Events } from "@ente/shared/events";
 import { getToken, getUserID } from "@ente/shared/storage/localStorage/helpers";
 import debounce from "debounce";
 import PQueue from "p-queue";
-import { getMLSyncJobConfig } from "services/machineLearning/machineLearningService";
 import mlIDbStorage from "services/ml/db";
+import { createFaceComlinkWorker } from "services/ml/face";
+import type { DedicatedMLWorker } from "services/ml/face.worker";
 import { MLSyncResult } from "services/ml/types";
 import { EnteFile } from "types/file";
-import { getDedicatedMLWorker } from "utils/comlink/ComlinkMLWorker";
-import { DedicatedMLWorker } from "worker/ml.worker";
 import { logQueueStats } from "./machineLearningService";
 
 const LIVE_SYNC_IDLE_DEBOUNCE_SEC = 30;
@@ -21,32 +20,30 @@ export type JobState = "Scheduled" | "Running" | "NotScheduled";
 
 export interface JobConfig {
     intervalSec: number;
-    maxItervalSec: number;
     backoffMultiplier: number;
 }
 
-export interface JobResult {
+export interface MLSyncJobResult {
     shouldBackoff: boolean;
+    mlSyncResult: MLSyncResult;
 }
 
-export class SimpleJob<R extends JobResult> {
-    private config: JobConfig;
-    private runCallback: () => Promise<R>;
+export class MLSyncJob {
+    private runCallback: () => Promise<MLSyncJobResult>;
     private state: JobState;
     private stopped: boolean;
     private intervalSec: number;
     private nextTimeoutId: ReturnType<typeof setTimeout>;
 
-    constructor(config: JobConfig, runCallback: () => Promise<R>) {
-        this.config = config;
+    constructor(runCallback: () => Promise<MLSyncJobResult>) {
         this.runCallback = runCallback;
         this.state = "NotScheduled";
         this.stopped = true;
-        this.intervalSec = this.config.intervalSec;
+        this.resetInterval();
     }
 
     public resetInterval() {
-        this.intervalSec = this.config.intervalSec;
+        this.intervalSec = 5;
     }
 
     public start() {
@@ -79,10 +76,7 @@ export class SimpleJob<R extends JobResult> {
         try {
             const jobResult = await this.runCallback();
             if (jobResult && jobResult.shouldBackoff) {
-                this.intervalSec = Math.min(
-                    this.config.maxItervalSec,
-                    this.intervalSec * this.config.backoffMultiplier,
-                );
+                this.intervalSec = Math.min(960, this.intervalSec * 2);
             } else {
                 this.resetInterval();
             }
@@ -109,12 +103,6 @@ export class SimpleJob<R extends JobResult> {
     }
 }
 
-export interface MLSyncJobResult extends JobResult {
-    mlSyncResult: MLSyncResult;
-}
-
-export class MLSyncJob extends SimpleJob<MLSyncJobResult> {}
-
 class MLWorkManager {
     private mlSyncJob: MLSyncJob;
     private syncJobWorker: ComlinkWorker<typeof DedicatedMLWorker>;
@@ -135,7 +123,6 @@ class MLWorkManager {
         });
         this.mlSearchEnabled = false;
 
-        eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this);
         this.debouncedLiveSyncIdle = debounce(
             () => this.onLiveSyncIdle(),
             LIVE_SYNC_IDLE_DEBOUNCE_SEC * 1000,
@@ -187,26 +174,12 @@ class MLWorkManager {
         }
     }
 
-    // Handlers
-    private async appStartHandler() {
-        log.info("appStartHandler");
-        try {
-            this.startSyncJob();
-        } catch (e) {
-            log.error("Failed in ML appStart Handler", e);
-        }
-    }
-
-    private async logoutHandler() {
-        log.info("logoutHandler");
-        try {
-            this.stopSyncJob();
-            this.mlSyncJob = undefined;
-            await this.terminateLiveSyncWorker();
-            await mlIDbStorage.clearMLDB();
-        } catch (e) {
-            log.error("Failed in ML logout Handler", e);
-        }
+    async logout() {
+        this.setMlSearchEnabled(false);
+        this.stopSyncJob();
+        this.mlSyncJob = undefined;
+        await this.terminateLiveSyncWorker();
+        await mlIDbStorage.clearMLDB();
     }
 
     private async fileUploadedHandler(arg: {
@@ -238,7 +211,7 @@ class MLWorkManager {
     // Live Sync
     private async getLiveSyncWorker() {
         if (!this.liveSyncWorker) {
-            this.liveSyncWorker = getDedicatedMLWorker("ml-sync-live");
+            this.liveSyncWorker = createFaceComlinkWorker("ml-sync-live");
         }
 
         return await this.liveSyncWorker.remote;
@@ -286,7 +259,7 @@ class MLWorkManager {
     // Sync Job
     private async getSyncJobWorker() {
         if (!this.syncJobWorker) {
-            this.syncJobWorker = getDedicatedMLWorker("ml-sync-job");
+            this.syncJobWorker = createFaceComlinkWorker("ml-sync-job");
         }
 
         return await this.syncJobWorker.remote;
@@ -344,11 +317,8 @@ class MLWorkManager {
                 log.info("User not logged in, not starting ml sync job");
                 return;
             }
-            const mlSyncJobConfig = await getMLSyncJobConfig();
             if (!this.mlSyncJob) {
-                this.mlSyncJob = new MLSyncJob(mlSyncJobConfig, () =>
-                    this.runMLSyncJob(),
-                );
+                this.mlSyncJob = new MLSyncJob(() => this.runMLSyncJob());
             }
             this.mlSyncJob.start();
         } catch (e) {

+ 2 - 2
web/apps/photos/src/services/machineLearning/peopleService.ts

@@ -2,7 +2,7 @@ import log from "@/next/log";
 import mlIDbStorage from "services/ml/db";
 import { Face, MLSyncContext, Person } from "services/ml/types";
 import FaceService, { isDifferentOrOld } from "./faceService";
-import { getLocalFile, getOriginalImageBitmap } from "./readerService";
+import { fetchImageBitmap, getLocalFile } from "./readerService";
 
 class PeopleService {
     async syncPeopleIndex(syncContext: MLSyncContext) {
@@ -58,7 +58,7 @@ class PeopleService {
 
             if (personFace && !personFace.crop?.cacheKey) {
                 const file = await getLocalFile(personFace.fileId);
-                const imageBitmap = await getOriginalImageBitmap(file);
+                const imageBitmap = await fetchImageBitmap(file);
                 await FaceService.saveFaceCrop(
                     imageBitmap,
                     personFace,

+ 8 - 25
web/apps/photos/src/services/machineLearning/readerService.ts

@@ -1,7 +1,6 @@
 import { FILE_TYPE } from "@/media/file-type";
 import { decodeLivePhoto } from "@/media/live-photo";
 import log from "@/next/log";
-import PQueue from "p-queue";
 import DownloadManager from "services/download";
 import { getLocalFiles } from "services/fileService";
 import { Dimensions } from "services/ml/geom";
@@ -41,7 +40,7 @@ class ReaderService {
                     fileContext.enteFile.metadata.fileType,
                 )
             ) {
-                fileContext.imageBitmap = await getOriginalImageBitmap(
+                fileContext.imageBitmap = await fetchImageBitmap(
                     fileContext.enteFile,
                 );
             } else {
@@ -106,22 +105,12 @@ export function getFaceId(detectedFace: DetectedFace, imageDims: Dimensions) {
     return faceID;
 }
 
-async function getImageBlobBitmap(blob: Blob): Promise<ImageBitmap> {
-    return await createImageBitmap(blob);
-}
+export const fetchImageBitmap = async (file: EnteFile) =>
+    fetchRenderableBlob(file).then(createImageBitmap);
 
-async function getOriginalFile(file: EnteFile, queue?: PQueue) {
-    let fileStream;
-    if (queue) {
-        fileStream = await queue.add(() => DownloadManager.getFile(file));
-    } else {
-        fileStream = await DownloadManager.getFile(file);
-    }
-    return new Response(fileStream).blob();
-}
-
-async function getOriginalConvertedFile(file: EnteFile, queue?: PQueue) {
-    const fileBlob = await getOriginalFile(file, queue);
+async function fetchRenderableBlob(file: EnteFile) {
+    const fileStream = await DownloadManager.getFile(file);
+    const fileBlob = await new Response(fileStream).blob();
     if (file.metadata.fileType === FILE_TYPE.IMAGE) {
         return await getRenderableImage(file.metadata.title, fileBlob);
     } else {
@@ -133,17 +122,11 @@ async function getOriginalConvertedFile(file: EnteFile, queue?: PQueue) {
     }
 }
 
-export async function getOriginalImageBitmap(file: EnteFile, queue?: PQueue) {
-    const fileBlob = await getOriginalConvertedFile(file, queue);
-    log.info("[MLService] Got file: ", file.id.toString());
-    return getImageBlobBitmap(fileBlob);
-}
-
 export async function getThumbnailImageBitmap(file: EnteFile) {
     const thumb = await DownloadManager.getThumbnail(file);
     log.info("[MLService] Got thumbnail: ", file.id.toString());
 
-    return getImageBlobBitmap(new Blob([thumb]));
+    return createImageBitmap(new Blob([thumb]));
 }
 
 export async function getLocalFileImageBitmap(
@@ -152,5 +135,5 @@ export async function getLocalFileImageBitmap(
 ) {
     let fileBlob = localFile as Blob;
     fileBlob = await getRenderableImage(enteFile.metadata.title, fileBlob);
-    return getImageBlobBitmap(fileBlob);
+    return createImageBitmap(fileBlob);
 }

+ 7 - 3
web/apps/photos/src/services/ml/db.ts

@@ -12,7 +12,6 @@ import isElectron from "is-electron";
 import {
     DEFAULT_ML_SEARCH_CONFIG,
     DEFAULT_ML_SYNC_CONFIG,
-    DEFAULT_ML_SYNC_JOB_CONFIG,
     MAX_ML_SYNC_ERROR_COUNT,
 } from "services/machineLearning/machineLearningService";
 import { Face, MLLibraryData, MlFileData, Person } from "services/ml/types";
@@ -27,7 +26,6 @@ export interface IndexStatus {
 
 interface Config {}
 
-export const ML_SYNC_JOB_CONFIG_NAME = "ml-sync-job";
 export const ML_SYNC_CONFIG_NAME = "ml-sync";
 export const ML_SEARCH_CONFIG_NAME = "ml-search";
 
@@ -136,12 +134,14 @@ class MLIDbStorage {
                     // TODO: update configs if version is updated in defaults
                     db.createObjectStore("configs");
 
+                    /*
                     await tx
                         .objectStore("configs")
                         .add(
                             DEFAULT_ML_SYNC_JOB_CONFIG,
-                            ML_SYNC_JOB_CONFIG_NAME,
+                            "ml-sync-job",
                         );
+                    */
                     await tx
                         .objectStore("configs")
                         .add(DEFAULT_ML_SYNC_CONFIG, ML_SYNC_CONFIG_NAME);
@@ -163,6 +163,10 @@ class MLIDbStorage {
                             .objectStore("configs")
                             .delete(ML_SEARCH_CONFIG_NAME);
 
+                        await tx
+                            .objectStore("configs")
+                            .delete("ml-sync-job");
+
                         await tx
                             .objectStore("configs")
                             .add(

+ 8 - 0
web/apps/photos/src/services/ml/face.ts

@@ -0,0 +1,8 @@
+import { ComlinkWorker } from "@/next/worker/comlink-worker";
+import type { DedicatedMLWorker } from "services/ml/face.worker";
+
+const createFaceWebWorker = () =>
+    new Worker(new URL("face.worker.ts", import.meta.url));
+
+export const createFaceComlinkWorker = (name: string) =>
+    new ComlinkWorker<typeof DedicatedMLWorker>(name, createFaceWebWorker());

+ 0 - 0
web/apps/photos/src/worker/ml.worker.ts → web/apps/photos/src/services/ml/face.worker.ts


+ 0 - 20
web/apps/photos/src/services/userService.ts

@@ -1,11 +1,8 @@
 import log from "@/next/log";
 import { putAttributes } from "@ente/accounts/api/user";
-import { logoutUser } from "@ente/accounts/services/user";
-import { getRecoveryKey } from "@ente/shared/crypto/helpers";
 import { ApiError } from "@ente/shared/error";
 import HTTPService from "@ente/shared/network/HTTPService";
 import { getEndpoint, getFamilyPortalURL } from "@ente/shared/network/api";
-import localForage from "@ente/shared/storage/localForage";
 import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
 import {
     getToken,
@@ -104,10 +101,6 @@ export const getRoadmapRedirectURL = async () => {
     }
 };
 
-export const clearFiles = async () => {
-    await localForage.clear();
-};
-
 export const isTokenValid = async (token: string) => {
     try {
         const resp = await HTTPService.get(
@@ -233,19 +226,6 @@ export const deleteAccount = async (
     }
 };
 
-// Ensure that the keys in local storage are not malformed by verifying that the
-// recoveryKey can be decrypted with the masterKey.
-// Note: This is not bullet-proof.
-export const validateKey = async () => {
-    try {
-        await getRecoveryKey();
-        return true;
-    } catch (e) {
-        await logoutUser();
-        return false;
-    }
-};
-
 export const getFaceSearchEnabledStatus = async () => {
     try {
         const token = getToken();

+ 0 - 13
web/apps/photos/src/utils/comlink/ComlinkMLWorker.ts

@@ -1,13 +0,0 @@
-import { haveWindow } from "@/next/env";
-import { ComlinkWorker } from "@/next/worker/comlink-worker";
-import { type DedicatedMLWorker } from "worker/ml.worker";
-
-export const getDedicatedMLWorker = (name: string) => {
-    if (haveWindow()) {
-        const cryptoComlinkWorker = new ComlinkWorker<typeof DedicatedMLWorker>(
-            name ?? "ente-ml-worker",
-            new Worker(new URL("worker/ml.worker.ts", import.meta.url)),
-        );
-        return cryptoComlinkWorker;
-    }
-};

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

@@ -1,6 +1,5 @@
 import { ensureElectron } from "@/next/electron";
 import { AppUpdate } from "@/next/types/ipc";
-import { logoutUser } from "@ente/accounts/services/user";
 import { DialogBoxAttributes } from "@ente/shared/components/DialogBox/types";
 import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
 import InfoOutlined from "@mui/icons-material/InfoRounded";
@@ -121,14 +120,16 @@ export const getSubscriptionPurchaseSuccessMessage = (
     ),
 });
 
-export const getSessionExpiredMessage = (): DialogBoxAttributes => ({
+export const getSessionExpiredMessage = (
+    action: () => void,
+): DialogBoxAttributes => ({
     title: t("SESSION_EXPIRED"),
     content: t("SESSION_EXPIRED_MESSAGE"),
 
     nonClosable: true,
     proceed: {
         text: t("LOGIN"),
-        action: logoutUser,
+        action,
         variant: "accent",
     },
 });

+ 1 - 1
web/packages/accounts/api/user.ts

@@ -43,7 +43,7 @@ export const putAttributes = (token: string, keyAttributes: KeyAttributes) =>
         },
     );
 
-export const _logout = async () => {
+export const logout = async () => {
     try {
         const token = getToken();
         await HTTPService.post(`${ENDPOINT}/users/logout`, null, undefined, {

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

@@ -50,10 +50,11 @@ import {
     generateSRPSetupAttributes,
     loginViaSRP,
 } from "../services/srp";
-import { logoutUser } from "../services/user";
 import { SRPAttributes } from "../types/srp";
 
 export default function Credentials({ appContext, appName }: PageProps) {
+    const { logout } = appContext;
+
     const [srpAttributes, setSrpAttributes] = useState<SRPAttributes>();
     const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
     const [user, setUser] = useState<User>();
@@ -275,7 +276,7 @@ export default function Credentials({ appContext, appName }: PageProps) {
                     <LinkButton onClick={redirectToRecoverPage}>
                         {t("FORGOT_PASSWORD")}
                     </LinkButton>
-                    <LinkButton onClick={logoutUser}>
+                    <LinkButton onClick={logout}>
                         {t("CHANGE_EMAIL")}
                     </LinkButton>
                 </FormPaperFooter>

+ 3 - 2
web/packages/accounts/pages/generate.tsx

@@ -1,7 +1,6 @@
 import log from "@/next/log";
 import { putAttributes } from "@ente/accounts/api/user";
 import { configureSRP } from "@ente/accounts/services/srp";
-import { logoutUser } from "@ente/accounts/services/user";
 import { generateKeyAndSRPAttributes } from "@ente/accounts/utils/srp";
 import {
     generateAndSaveIntermediateKeyAttributes,
@@ -31,6 +30,8 @@ import { KeyAttributes, User } from "@ente/shared/user/types";
 import { useRouter } from "next/router";
 
 export default function Generate({ appContext, appName }: PageProps) {
+    const { logout } = appContext;
+
     const [token, setToken] = useState<string>();
     const [user, setUser] = useState<User>();
     const [recoverModalView, setRecoveryModalView] = useState(false);
@@ -113,7 +114,7 @@ export default function Generate({ appContext, appName }: PageProps) {
                             buttonText={t("SET_PASSPHRASE")}
                         />
                         <FormPaperFooter>
-                            <LinkButton onClick={logoutUser}>
+                            <LinkButton onClick={logout}>
                                 {t("GO_BACK")}
                             </LinkButton>
                         </FormPaperFooter>

+ 3 - 2
web/packages/accounts/pages/two-factor/recover.tsx

@@ -2,7 +2,6 @@ import log from "@/next/log";
 import { recoverTwoFactor, removeTwoFactor } from "@ente/accounts/api/user";
 import { PAGES } from "@ente/accounts/constants/pages";
 import { TwoFactorType } from "@ente/accounts/constants/twofactor";
-import { logoutUser } from "@ente/accounts/services/user";
 import { PageProps } from "@ente/shared/apps/types";
 import { VerticallyCentered } from "@ente/shared/components/Container";
 import { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types";
@@ -33,6 +32,8 @@ export default function Recover({
     appContext,
     twoFactorType = TwoFactorType.TOTP,
 }: PageProps) {
+    const { logout } = appContext;
+
     const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] =
         useState<B64EncryptionResult>(null);
     const [sessionID, setSessionID] = useState(null);
@@ -77,7 +78,7 @@ export default function Recover({
                     e instanceof ApiError &&
                     e.httpStatusCode === HttpStatusCode.NotFound
                 ) {
-                    logoutUser();
+                    logout();
                 } else {
                     log.error("two factor recovery page setup failed", e);
                     setDoesHaveEncryptedRecoveryKey(false);

+ 8 - 4
web/packages/accounts/pages/two-factor/verify.tsx

@@ -3,7 +3,7 @@ import VerifyTwoFactor, {
     VerifyTwoFactorCallback,
 } from "@ente/accounts/components/two-factor/VerifyForm";
 import { PAGES } from "@ente/accounts/constants/pages";
-import { logoutUser } from "@ente/accounts/services/user";
+
 import type { PageProps } from "@ente/shared/apps/types";
 import { VerticallyCentered } from "@ente/shared/components/Container";
 import FormPaper from "@ente/shared/components/Form/FormPaper";
@@ -19,7 +19,11 @@ import { t } from "i18next";
 import { useRouter } from "next/router";
 import { useEffect, useState } from "react";
 
-export const TwoFactorVerify: React.FC<PageProps> = () => {
+export const TwoFactorVerify: React.FC<PageProps> = ({
+    appContext,
+}: PageProps) => {
+    const { logout } = appContext;
+
     const [sessionID, setSessionID] = useState("");
 
     const router = useRouter();
@@ -60,7 +64,7 @@ export const TwoFactorVerify: React.FC<PageProps> = () => {
                 e instanceof ApiError &&
                 e.httpStatusCode === HttpStatusCode.NotFound
             ) {
-                logoutUser();
+                logout();
             } else {
                 throw e;
             }
@@ -79,7 +83,7 @@ export const TwoFactorVerify: React.FC<PageProps> = () => {
                     >
                         {t("LOST_DEVICE")}
                     </LinkButton>
-                    <LinkButton onClick={logoutUser}>
+                    <LinkButton onClick={logout}>
                         {t("CHANGE_EMAIL")}
                     </LinkButton>
                 </FormPaperFooter>

+ 5 - 4
web/packages/accounts/pages/verify.tsx

@@ -16,7 +16,7 @@ import SingleInputForm, {
 import { ApiError } from "@ente/shared/error";
 import { getAccountsURL } from "@ente/shared/network/api";
 import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
-import { clearFiles } from "@ente/shared/storage/localForage/helpers";
+import localForage from "@ente/shared/storage/localForage";
 import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
 import {
     getLocalReferralSource,
@@ -30,10 +30,11 @@ import { useRouter } from "next/router";
 import { putAttributes, sendOtt, verifyOtt } from "../api/user";
 import { PAGES } from "../constants/pages";
 import { configureSRP } from "../services/srp";
-import { logoutUser } from "../services/user";
 import { SRPSetupAttributes } from "../types/srp";
 
 export default function VerifyPage({ appContext, appName }: PageProps) {
+    const { logout } = appContext;
+
     const [email, setEmail] = useState("");
     const [resend, setResend] = useState(0);
 
@@ -121,7 +122,7 @@ export default function VerifyPage({ appContext, appName }: PageProps) {
                         await configureSRP(srpSetupAttributes);
                     }
                 }
-                clearFiles();
+                localForage.clear();
                 setIsFirstLogin(true);
                 const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL);
                 InMemoryStore.delete(MS_KEYS.REDIRECT_URL);
@@ -191,7 +192,7 @@ export default function VerifyPage({ appContext, appName }: PageProps) {
                     )}
                     {resend === 1 && <span>{t("SENDING")}</span>}
                     {resend === 2 && <span>{t("SENT")}</span>}
-                    <LinkButton onClick={logoutUser}>
+                    <LinkButton onClick={logout}>
                         {t("CHANGE_EMAIL")}
                     </LinkButton>
                 </FormPaperFooter>

+ 50 - 0
web/packages/accounts/services/logout.ts

@@ -0,0 +1,50 @@
+import { clearCaches } from "@/next/blob-cache";
+import log from "@/next/log";
+import InMemoryStore from "@ente/shared/storage/InMemoryStore";
+import localForage from "@ente/shared/storage/localForage";
+import { clearData } from "@ente/shared/storage/localStorage";
+import { clearKeys } from "@ente/shared/storage/sessionStorage";
+import { logout as remoteLogout } from "../api/user";
+
+/**
+ * Logout sequence common to all apps that rely on the accounts package.
+ *
+ * [Note: Do not throw during logout]
+ *
+ * This function is guaranteed to not thrown any errors, and will try to
+ * independently complete all the steps in the sequence that can be completed.
+ * This allows the user to logout and start again even if somehow their account
+ * gets in an unexpected state.
+ */
+export const accountLogout = async () => {
+    try {
+        await remoteLogout();
+    } catch (e) {
+        log.error("Ignoring error during logout (remote)", e);
+    }
+    try {
+        InMemoryStore.clear();
+    } catch (e) {
+        log.error("Ignoring error during logout (in-memory store)", e);
+    }
+    try {
+        clearKeys();
+    } catch (e) {
+        log.error("Ignoring error during logout (session store)", e);
+    }
+    try {
+        clearData();
+    } catch (e) {
+        log.error("Ignoring error during logout (local storage)", e);
+    }
+    try {
+        await localForage.clear();
+    } catch (e) {
+        log.error("Ignoring error during logout (local forage)", e);
+    }
+    try {
+        await clearCaches();
+    } catch (e) {
+        log.error("Ignoring error during logout (cache)", e);
+    }
+};

+ 0 - 67
web/packages/accounts/services/user.ts

@@ -1,67 +0,0 @@
-import { clearCaches } from "@/next/blob-cache";
-import log from "@/next/log";
-import { Events, eventBus } from "@ente/shared/events";
-import InMemoryStore from "@ente/shared/storage/InMemoryStore";
-import { clearFiles } from "@ente/shared/storage/localForage/helpers";
-import { clearData } from "@ente/shared/storage/localStorage";
-import { clearKeys } from "@ente/shared/storage/sessionStorage";
-import router from "next/router";
-import { _logout } from "../api/user";
-import { PAGES } from "../constants/pages";
-
-export const logoutUser = async () => {
-    try {
-        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 clearCaches();
-    } catch (e) {
-        log.error("Ignoring error when clearing caches", e);
-    }
-    try {
-        await clearFiles();
-    } catch (e) {
-        log.error("Ignoring error when clearing files", e);
-    }
-    const electron = globalThis.electron;
-    if (electron) {
-        try {
-            await electron.watch.reset();
-        } catch (e) {
-            log.error("Ignoring error when resetting native folder watches", e);
-        }
-        try {
-            await electron.clearConvertToMP4Results();
-        } catch (e) {
-            log.error("Ignoring error when clearing convert-to-mp4 results", e);
-        }
-        try {
-            await electron.clearStores();
-        } catch (e) {
-            log.error("Ignoring error when clearing native stores", e);
-        }
-    }
-    try {
-        eventBus.emit(Events.LOGOUT);
-    } catch (e) {
-        log.error("Ignoring error in event-bus logout handlers", e);
-    }
-    router.push(PAGES.ROOT);
-};

+ 2 - 23
web/packages/next/types/ipc.ts

@@ -64,19 +64,9 @@ export interface Electron {
     selectDirectory: () => Promise<string | undefined>;
 
     /**
-     * Clear any stored data.
-     *
-     * This is a coarse single shot cleanup, meant for use in clearing any
-     * persisted Electron side state during logout.
-     */
-    clearStores: () => void;
-
-    /**
-     * Clear an state corresponding to in-flight convert-to-mp4 requests.
-     *
-     * This is meant for use during logout.
+     * Perform any logout related cleanup of native side state.
      */
-    clearConvertToMP4Results: () => void;
+    logout: () => Promise<void>;
 
     /**
      * Return the previously saved encryption key from persistent safe storage.
@@ -487,17 +477,6 @@ export interface Electron {
          * The returned paths are guaranteed to use POSIX separators ('/').
          */
         findFiles: (folderPath: string) => Promise<string[]>;
-
-        /**
-         * Stop watching all existing folder watches and remove any callbacks.
-         *
-         * This function is meant to be called when the user logs out. It stops
-         * all existing folder watches and forgets about any "on*" callback
-         * functions that have been registered.
-         *
-         * The persisted state itself gets cleared via {@link clearStores}.
-         */
-        reset: () => Promise<void>;
     };
 
     // - Upload

+ 1 - 0
web/packages/shared/apps/types.ts

@@ -7,6 +7,7 @@ export interface PageProps {
         showNavBar: (show: boolean) => void;
         isMobile: boolean;
         setDialogBoxAttributesV2: SetDialogBoxAttributesV2;
+        logout: () => void;
     };
     appName: APPS;
     twoFactorType?: TwoFactorType;

+ 0 - 0
web/packages/shared/storage/localForage/index.ts → web/packages/shared/storage/localForage.ts


+ 0 - 5
web/packages/shared/storage/localForage/helpers.ts

@@ -1,5 +0,0 @@
-import localForage from ".";
-
-export const clearFiles = async () => {
-    await localForage.clear();
-};