diff --git a/desktop/package.json b/desktop/package.json index 16ba23eb96..032953d8d0 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,7 +11,7 @@ "build-main:quick": "tsc && electron-builder --dir --config.compression=store --config.mac.identity=null", "build-renderer": "cd ../web && yarn install && yarn build:photos && cd ../desktop && shx rm -f out && shx ln -sf ../web/apps/photos/out out", "build:quick": "yarn build-renderer && yarn build-main:quick", - "dev": "concurrently --names 'main,rndr' \"yarn dev-main\" \"yarn dev-renderer\"", + "dev": "concurrently --kill-others --success first --names 'main,rndr' \"yarn dev-main\" \"yarn dev-renderer\"", "dev-main": "tsc && electron app/main.js", "dev-renderer": "cd ../web && yarn install && yarn dev:photos", "postinstall": "electron-builder install-app-deps", diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 68b2cc51cc..809b153b8b 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -8,7 +8,8 @@ * * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process */ -import { app, BrowserWindow, Menu } from "electron/main"; +import { nativeImage } from "electron"; +import { app, BrowserWindow, Menu, Tray } from "electron/main"; import serveNextAt from "next-electron-server"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; @@ -16,45 +17,47 @@ import os from "node:os"; import path from "node:path"; import { addAllowOriginHeader, - createWindow, - handleDockIconHideOnAutoLaunch, handleDownloads, handleExternalLinks, - setupMacWindowOnDockIconClick, - setupTrayItem, } from "./main/init"; import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc"; import log, { initLogging } from "./main/log"; -import { createApplicationMenu } from "./main/menu"; +import { createApplicationMenu, createTrayContextMenu } from "./main/menu"; import { setupAutoUpdater } from "./main/services/app-update"; +import autoLauncher from "./main/services/autoLauncher"; import { initWatcher } from "./main/services/chokidar"; +import { userPreferences } from "./main/stores/user-preferences"; import { isDev } from "./main/util"; -let appIsQuitting = false; - -let updateIsAvailable = false; - -export const isAppQuitting = (): boolean => { - return appIsQuitting; -}; - -export const setIsAppQuitting = (value: boolean): void => { - appIsQuitting = value; -}; - -export const isUpdateAvailable = (): boolean => { - return updateIsAvailable; -}; - -export const setIsUpdateAvailable = (value: boolean): void => { - updateIsAvailable = value; -}; - /** * The URL where the renderer HTML is being served from. */ export const rendererURL = "next://app"; +/** + * We want to hide our window instead of closing it when the user presses the + * cross button on the window. + * + * > This is because there is 1. a perceptible initial window creation time for + * > our app, and 2. because the long running processes like export and watch + * > folders are tied to the lifetime of the window and otherwise won't run in + * > the background. + * + * Intercepting the window close event and using that to instead hide it is + * easy, however that prevents the actual app quit to stop working (since the + * window never gets closed). + * + * So to achieve our original goal (hide window instead of closing) without + * disabling expected app quits, we keep a flag, and we turn it on when we're + * part of the quit sequence. When this flag is on, we bypass the code that + * prevents the window from being closed. + */ +let shouldAllowWindowClose = false; + +export const allowWindowClose = (): void => { + shouldAllowWindowClose = true; +}; + /** * next-electron-server allows up to directly use the output of `next build` in * production mode and `next dev` in development mode, whilst keeping the rest @@ -68,9 +71,7 @@ export const rendererURL = "next://app"; * For more details, see this comparison: * https://github.com/HaNdTriX/next-electron-server/issues/5 */ -const setupRendererServer = () => { - serveNextAt(rendererURL); -}; +const setupRendererServer = () => serveNextAt(rendererURL); /** * Log a standard startup banner. @@ -87,29 +88,126 @@ const logStartupBanner = () => { log.info("Running on", { platform, osRelease, systemVersion }); }; -function enableSharedArrayBufferSupport() { - app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer"); -} - /** * [Note: Increased disk cache for the desktop app] * * Set the "disk-cache-size" command line flag to ask the Chromium process to * use a larger size for the caches that it keeps on disk. This allows us to use - * the same web-native caching mechanism on both the web and the desktop app, - * just ask the embedded Chromium to be a bit more generous in disk usage when + * the web based caching mechanisms on both the web and the desktop app, just + * ask the embedded Chromium to be a bit more generous in disk usage when * running as the desktop app. * - * The size we provide is in bytes. We set it to a large value, 5 GB (5 * 1024 * - * 1024 * 1024 = 5368709120) + * The size we provide is in bytes. * https://www.electronjs.org/docs/latest/api/command-line-switches#--disk-cache-sizesize * * Note that increasing the disk cache size does not guarantee that Chromium * will respect in verbatim, it uses its own heuristics atop this hint. * https://superuser.com/questions/378991/what-is-chrome-default-cache-size-limit/1577693#1577693 */ -const increaseDiskCache = () => { - app.commandLine.appendSwitch("disk-cache-size", "5368709120"); +const increaseDiskCache = () => + app.commandLine.appendSwitch( + "disk-cache-size", + `${5 * 1024 * 1024 * 1024}`, // 5 GB + ); + +/** + * Create an return the {@link BrowserWindow} that will form our app's UI. + * + * This window will show the HTML served from {@link rendererURL}. + */ +const createMainWindow = async () => { + // Create the main window. This'll show our web content. + const window = new BrowserWindow({ + webPreferences: { + preload: path.join(app.getAppPath(), "preload.js"), + sandbox: true, + }, + // The color to show in the window until the web content gets loaded. + // See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property + backgroundColor: "black", + // We'll show it conditionally depending on `wasAutoLaunched` later. + show: false, + }); + + const wasAutoLaunched = await autoLauncher.wasAutoLaunched(); + if (wasAutoLaunched) { + // Don't automatically show the app's window if we were auto-launched. + // On macOS, also hide the dock icon on macOS. + if (process.platform == "darwin") app.dock.hide(); + } else { + // Show our window (maximizing it) otherwise. + window.maximize(); + } + + window.loadURL(rendererURL); + + // Open the DevTools automatically when running in dev mode + if (isDev) window.webContents.openDevTools(); + + window.webContents.on("render-process-gone", (_, details) => { + log.error(`render-process-gone: ${details}`); + window.webContents.reload(); + }); + + window.webContents.on("unresponsive", () => { + log.error( + "Main window's webContents are unresponsive, will restart the renderer process", + ); + window.webContents.forcefullyCrashRenderer(); + }); + + window.on("close", (event) => { + if (!shouldAllowWindowClose) { + event.preventDefault(); + window.hide(); + } + return false; + }); + + window.on("hide", () => { + // On macOS, when hiding the window also hide the app's icon in the dock + // if the user has selected the Settings > Hide dock icon checkbox. + if (process.platform == "darwin" && userPreferences.get("hideDockIcon")) + app.dock.hide(); + }); + + window.on("show", () => { + if (process.platform == "darwin") app.dock.show(); + }); + + // Let ipcRenderer know when mainWindow is in the foreground so that it can + // in turn inform the renderer process. + window.on("focus", () => window.webContents.send("mainWindowFocus")); + + return window; +}; + +/** + * Add an icon for our app in the system tray. + * + * For example, these are the small icons that appear on the top right of the + * screen in the main menu bar on macOS. + */ +const setupTrayItem = (mainWindow: BrowserWindow) => { + // There are a total of 6 files corresponding to this tray icon. + // + // On macOS, use template images (filename needs to end with "Template.ext") + // https://www.electronjs.org/docs/latest/api/native-image#template-image-macos + // + // And for each (template or otherwise), there are 3 "retina" variants + // https://www.electronjs.org/docs/latest/api/native-image#high-resolution-image + const iconName = + process.platform == "darwin" + ? "taskbar-icon-Template.png" + : "taskbar-icon.png"; + const trayImgPath = path.join( + isDev ? "build" : process.resourcesPath, + iconName, + ); + const trayIcon = nativeImage.createFromPath(trayImgPath); + const tray = new Tray(trayIcon); + tray.setToolTip("Ente Photos"); + tray.setContextMenu(createTrayContextMenu(mainWindow)); }; /** @@ -141,14 +239,6 @@ const deleteLegacyDiskCacheDirIfExists = async () => { } }; -const attachEventHandlers = (mainWindow: BrowserWindow) => { - // 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("mainWindowFocus"), - ); -}; - const main = () => { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { @@ -156,22 +246,18 @@ const main = () => { return; } - let mainWindow: BrowserWindow; + let mainWindow: BrowserWindow | undefined; initLogging(); setupRendererServer(); logStartupBanner(); - handleDockIconHideOnAutoLaunch(); increaseDiskCache(); - enableSharedArrayBufferSupport(); app.on("second-instance", () => { // Someone tried to run a second instance, we should focus our window. if (mainWindow) { mainWindow.show(); - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } + if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } }); @@ -180,10 +266,9 @@ const main = () => { // // Note that some Electron APIs can only be used after this event occurs. app.on("ready", async () => { - mainWindow = await createWindow(); + mainWindow = await createMainWindow(); const watcher = initWatcher(mainWindow); setupTrayItem(mainWindow); - setupMacWindowOnDockIconClick(); Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); attachIPCHandlers(); attachFSWatchIPCHandlers(watcher); @@ -191,7 +276,6 @@ const main = () => { handleDownloads(mainWindow); handleExternalLinks(mainWindow); addAllowOriginHeader(mainWindow); - attachEventHandlers(mainWindow); try { deleteLegacyDiskCacheDirIfExists(); @@ -202,7 +286,11 @@ const main = () => { } }); - app.on("before-quit", () => setIsAppQuitting(true)); + // This is a macOS only event. Show our window when the user activates the + // app, e.g. by clicking on its dock icon. + app.on("activate", () => mainWindow?.show()); + + app.on("before-quit", allowWindowClose); }; main(); diff --git a/desktop/src/main/init.ts b/desktop/src/main/init.ts index 2bea075a33..d3e9b28b4b 100644 --- a/desktop/src/main/init.ts +++ b/desktop/src/main/init.ts @@ -1,102 +1,7 @@ -import { BrowserWindow, Tray, app, nativeImage, shell } from "electron"; +import { BrowserWindow, app, shell } from "electron"; import { existsSync } from "node:fs"; import path from "node:path"; -import { isAppQuitting, rendererURL } from "../main"; -import log from "./log"; -import { createTrayContextMenu } from "./menu"; -import { isPlatform } from "./platform"; -import autoLauncher from "./services/autoLauncher"; -import { getHideDockIconPreference } from "./services/userPreference"; -import { isDev } from "./util"; - -/** - * Create an return the {@link BrowserWindow} that will form our app's UI. - * - * This window will show the HTML served from {@link rendererURL}. - */ -export const createWindow = async () => { - // Create the main window. This'll show our web content. - const mainWindow = new BrowserWindow({ - webPreferences: { - preload: path.join(app.getAppPath(), "preload.js"), - }, - // The color to show in the window until the web content gets loaded. - // See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property - backgroundColor: "black", - // We'll show it conditionally depending on `wasAutoLaunched` later. - show: false, - }); - - const wasAutoLaunched = await autoLauncher.wasAutoLaunched(); - if (wasAutoLaunched) { - // Keep the macOS dock icon hidden if we were auto launched. - if (process.platform == "darwin") app.dock.hide(); - } else { - // Show our window (maximizing it) if this is not an auto-launch on - // login. - mainWindow.maximize(); - } - - mainWindow.loadURL(rendererURL); - - // Open the DevTools automatically when running in dev mode - if (isDev) mainWindow.webContents.openDevTools(); - - mainWindow.webContents.on("render-process-gone", (_, details) => { - log.error(`render-process-gone: ${details}`); - mainWindow.webContents.reload(); - }); - - mainWindow.webContents.on("unresponsive", () => { - log.error("webContents unresponsive"); - mainWindow.webContents.forcefullyCrashRenderer(); - }); - - mainWindow.on("close", function (event) { - if (!isAppQuitting()) { - event.preventDefault(); - mainWindow.hide(); - } - return false; - }); - - mainWindow.on("hide", () => { - // On macOS, also hide the app's icon in the dock if the user has - // selected the Settings > Hide dock icon checkbox. - const shouldHideDockIcon = getHideDockIconPreference(); - if (process.platform == "darwin" && shouldHideDockIcon) { - app.dock.hide(); - } - }); - - mainWindow.on("show", () => { - if (process.platform == "darwin") app.dock.show(); - }); - - return mainWindow; -}; - -export const setupTrayItem = (mainWindow: BrowserWindow) => { - // There are a total of 6 files corresponding to this tray icon. - // - // On macOS, use template images (filename needs to end with "Template.ext") - // https://www.electronjs.org/docs/latest/api/native-image#template-image-macos - // - // And for each (template or otherwise), there are 3 "retina" variants - // https://www.electronjs.org/docs/latest/api/native-image#high-resolution-image - const iconName = - process.platform == "darwin" - ? "taskbar-icon-Template.png" - : "taskbar-icon.png"; - const trayImgPath = path.join( - isDev ? "build" : process.resourcesPath, - iconName, - ); - const trayIcon = nativeImage.createFromPath(trayImgPath); - const tray = new Tray(trayIcon); - tray.setToolTip("Ente Photos"); - tray.setContextMenu(createTrayContextMenu(mainWindow)); -}; +import { rendererURL } from "../main"; export function handleDownloads(mainWindow: BrowserWindow) { mainWindow.webContents.session.on("will-download", (_, item) => { @@ -137,23 +42,6 @@ export function getUniqueSavePath(filename: string, directory: string): string { return uniqueFileSavePath; } -export function setupMacWindowOnDockIconClick() { - app.on("activate", function () { - const windows = BrowserWindow.getAllWindows(); - // we allow only one window - windows[0].show(); - }); -} - -export async function handleDockIconHideOnAutoLaunch() { - const shouldHideDockIcon = getHideDockIconPreference(); - const wasAutoLaunched = await autoLauncher.wasAutoLaunched(); - - if (isPlatform("mac") && shouldHideDockIcon && wasAutoLaunched) { - app.dock.hide(); - } -} - function lowerCaseHeaders(responseHeaders: Record) { const headers: Record = {}; for (const key of Object.keys(responseHeaders)) { diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index 7801e77aec..3441f3f2ab 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -5,13 +5,10 @@ import { MenuItemConstructorOptions, shell, } from "electron"; -import { setIsAppQuitting } from "../main"; +import { allowWindowClose } from "../main"; import { forceCheckForAppUpdates } from "./services/app-update"; import autoLauncher from "./services/autoLauncher"; -import { - getHideDockIconPreference, - setHideDockIconPreference, -} from "./services/userPreference"; +import { userPreferences } from "./stores/user-preferences"; import { openLogDirectory } from "./util"; /** Create and return the entries in the app's main menu bar */ @@ -21,7 +18,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => { // Whenever the menu is redrawn the current value of these variables is used // to set the checked state for the various settings checkboxes. let isAutoLaunchEnabled = await autoLauncher.isEnabled(); - let shouldHideDockIcon = getHideDockIconPreference(); + let shouldHideDockIcon = userPreferences.get("hideDockIcon"); const macOSOnly = (options: MenuItemConstructorOptions[]) => process.platform == "darwin" ? options : []; @@ -39,7 +36,9 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => { }; const toggleHideDockIcon = () => { - setHideDockIconPreference(!shouldHideDockIcon); + // Persist + userPreferences.set("hideDockIcon", !shouldHideDockIcon); + // And update the in-memory state shouldHideDockIcon = !shouldHideDockIcon; }; @@ -196,7 +195,7 @@ export const createTrayContextMenu = (mainWindow: BrowserWindow) => { }; const handleClose = () => { - setIsAppQuitting(true); + allowWindowClose(); app.quit(); }; diff --git a/desktop/src/main/services/app-update.ts b/desktop/src/main/services/app-update.ts index 272a1ca6c6..b474485016 100644 --- a/desktop/src/main/services/app-update.ts +++ b/desktop/src/main/services/app-update.ts @@ -2,10 +2,10 @@ 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 { allowWindowClose } from "../../main"; import { AppUpdateInfo } from "../../types/ipc"; import log from "../log"; -import { userPreferencesStore } from "../stores/user-preferences"; +import { userPreferences } from "../stores/user-preferences"; export const setupAutoUpdater = (mainWindow: BrowserWindow) => { autoUpdater.logger = electronLog; @@ -20,8 +20,8 @@ export const setupAutoUpdater = (mainWindow: BrowserWindow) => { * Check for app update check ignoring any previously saved skips / mutes. */ export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => { - userPreferencesStore.delete("skipAppVersion"); - userPreferencesStore.delete("muteUpdateNotificationVersion"); + userPreferences.delete("skipAppVersion"); + userPreferences.delete("muteUpdateNotificationVersion"); checkForUpdatesAndNotify(mainWindow); }; @@ -41,14 +41,12 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => { return; } - if (version === userPreferencesStore.get("skipAppVersion")) { + if (version === userPreferences.get("skipAppVersion")) { log.info(`User chose to skip version ${version}`); return; } - const mutedVersion = userPreferencesStore.get( - "muteUpdateNotificationVersion", - ); + const mutedVersion = userPreferences.get("muteUpdateNotificationVersion"); if (version === mutedVersion) { log.info(`User has muted update notifications for version ${version}`); return; @@ -74,8 +72,6 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => { log.error("Auto update failed", error); showUpdateDialog({ autoUpdatable: false, version }); }); - - setIsUpdateAvailable(true); }; /** @@ -87,12 +83,12 @@ export const appVersion = () => `v${app.getVersion()}`; export const updateAndRestart = () => { log.info("Restarting the app to apply update"); - setIsAppQuitting(true); + allowWindowClose(); autoUpdater.quitAndInstall(); }; export const updateOnNextRestart = (version: string) => - userPreferencesStore.set("muteUpdateNotificationVersion", version); + userPreferences.set("muteUpdateNotificationVersion", version); export const skipAppUpdate = (version: string) => - userPreferencesStore.set("skipAppVersion", version); + userPreferences.set("skipAppVersion", version); diff --git a/desktop/src/main/services/userPreference.ts b/desktop/src/main/services/userPreference.ts deleted file mode 100644 index c20657aa9c..0000000000 --- a/desktop/src/main/services/userPreference.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { userPreferencesStore } from "../stores/user-preferences"; - -export function getHideDockIconPreference() { - return userPreferencesStore.get("hideDockIcon"); -} - -export function setHideDockIconPreference(shouldHideDockIcon: boolean) { - userPreferencesStore.set("hideDockIcon", shouldHideDockIcon); -} diff --git a/desktop/src/main/stores/user-preferences.ts b/desktop/src/main/stores/user-preferences.ts index 396e7a86c7..a305f1a99b 100644 --- a/desktop/src/main/stores/user-preferences.ts +++ b/desktop/src/main/stores/user-preferences.ts @@ -18,7 +18,7 @@ const userPreferencesSchema: Schema = { }, }; -export const userPreferencesStore = new Store({ +export const userPreferences = new Store({ name: "userPreferences", schema: userPreferencesSchema, }); diff --git a/web/apps/photos/src/components/MachineLearning/ImageViews.tsx b/web/apps/photos/src/components/MachineLearning/ImageViews.tsx deleted file mode 100644 index 72800271b1..0000000000 --- a/web/apps/photos/src/components/MachineLearning/ImageViews.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import log from "@/next/log"; -import { cached } from "@ente/shared/storage/cacheStorage/helpers"; -import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; -import { User } from "@ente/shared/user/types"; -import { Skeleton, styled } from "@mui/material"; -import { useEffect, useState } from "react"; -import machineLearningService from "services/machineLearning/machineLearningService"; -import { imageBitmapToBlob } from "utils/image"; - -export const FaceCropsRow = styled("div")` - & > img { - width: 256px; - height: 256px; - } -`; - -export const FaceImagesRow = styled("div")` - & > img { - width: 112px; - height: 112px; - } -`; - -export function ImageCacheView(props: { - url: string; - cacheName: string; - faceID: string; -}) { - const [imageBlob, setImageBlob] = useState(); - - useEffect(() => { - let didCancel = false; - async function loadImage() { - try { - const user: User = getData(LS_KEYS.USER); - let blob: Blob; - if (!props.url || !props.cacheName || !user) { - blob = undefined; - } else { - blob = await cached( - props.cacheName, - props.url, - async () => { - try { - log.debug( - () => - `ImageCacheView: regenerate face crop for ${props.faceID}`, - ); - return machineLearningService.regenerateFaceCrop( - user.token, - user.id, - props.faceID, - ); - } catch (e) { - log.error( - "ImageCacheView: regenerate face crop failed", - e, - ); - } - }, - ); - } - - !didCancel && setImageBlob(blob); - } catch (e) { - log.error("ImageCacheView useEffect failed", e); - } - } - loadImage(); - return () => { - didCancel = true; - }; - }, [props.url, props.cacheName]); - - return ( - <> - - - ); -} - -export function ImageBitmapView(props: { image: ImageBitmap }) { - const [imageBlob, setImageBlob] = useState(); - - useEffect(() => { - let didCancel = false; - - async function loadImage() { - const blob = props.image && (await imageBitmapToBlob(props.image)); - !didCancel && setImageBlob(blob); - } - - loadImage(); - return () => { - didCancel = true; - }; - }, [props.image]); - - return ( - <> - - - ); -} - -export function ImageBlobView(props: { blob: Blob }) { - const [imgUrl, setImgUrl] = useState(); - - useEffect(() => { - try { - setImgUrl(props.blob && URL.createObjectURL(props.blob)); - } catch (e) { - console.error( - "ImageBlobView: can not create object url for blob: ", - props.blob, - e, - ); - } - }, [props.blob]); - - return imgUrl ? ( - - ) : ( - - ); -} diff --git a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableFaceSearch.tsx b/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableFaceSearch.tsx deleted file mode 100644 index a007cb3988..0000000000 --- a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableFaceSearch.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Button, - Checkbox, - DialogProps, - FormControlLabel, - FormGroup, - Link, - Stack, - Typography, -} from "@mui/material"; -import { EnteDrawer } from "components/EnteDrawer"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; -import { useEffect, useState } from "react"; -import { Trans } from "react-i18next"; -export default function EnableFaceSearch({ - open, - onClose, - enableFaceSearch, - onRootClose, -}) { - const [acceptTerms, setAcceptTerms] = useState(false); - - useEffect(() => { - setAcceptTerms(false); - }, [open]); - - const handleRootClose = () => { - onClose(); - onRootClose(); - }; - - const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { - if (reason === "backdropClick") { - handleRootClose(); - } else { - onClose(); - } - }; - return ( - - - - - - - ), - }} - /> - - - - setAcceptTerms(e.target.checked) - } - /> - } - label={t("FACE_SEARCH_CONFIRMATION")} - /> - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableMLSearch.tsx b/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableMLSearch.tsx deleted file mode 100644 index 1cd0a3b3f7..0000000000 --- a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/enableMLSearch.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Box, Button, Stack, Typography } from "@mui/material"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; -import { Trans } from "react-i18next"; -import { openLink } from "utils/common"; - -export default function EnableMLSearch({ - onClose, - enableMlSearch, - onRootClose, -}) { - const showDetails = () => - openLink("https://ente.io/blog/desktop-ml-beta", true); - - return ( - - - - - {" "} - - - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/index.tsx b/web/apps/photos/src/components/MachineLearning/MLSearchSettings/index.tsx deleted file mode 100644 index 9b33a984a1..0000000000 --- a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/index.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import log from "@/next/log"; -import { Box, DialogProps, Typography } from "@mui/material"; -import { EnteDrawer } from "components/EnteDrawer"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import { useContext, useState } from "react"; -import { Trans } from "react-i18next"; -import { - getFaceSearchEnabledStatus, - updateFaceSearchEnabledStatus, -} from "services/userService"; -import EnableFaceSearch from "./enableFaceSearch"; -import EnableMLSearch from "./enableMLSearch"; -import ManageMLSearch from "./manageMLSearch"; - -const MLSearchSettings = ({ open, onClose, onRootClose }) => { - const { - updateMlSearchEnabled, - mlSearchEnabled, - setDialogMessage, - somethingWentWrong, - startLoading, - finishLoading, - } = useContext(AppContext); - - const [enableFaceSearchView, setEnableFaceSearchView] = useState(false); - - const openEnableFaceSearch = () => { - setEnableFaceSearchView(true); - }; - const closeEnableFaceSearch = () => { - setEnableFaceSearchView(false); - }; - - const enableMlSearch = async () => { - try { - const hasEnabledFaceSearch = await getFaceSearchEnabledStatus(); - if (!hasEnabledFaceSearch) { - openEnableFaceSearch(); - } else { - updateMlSearchEnabled(true); - } - } catch (e) { - log.error("Enable ML search failed", e); - somethingWentWrong(); - } - }; - - const enableFaceSearch = async () => { - try { - startLoading(); - await updateFaceSearchEnabledStatus(true); - updateMlSearchEnabled(true); - closeEnableFaceSearch(); - finishLoading(); - } catch (e) { - log.error("Enable face search failed", e); - somethingWentWrong(); - } - }; - - const disableMlSearch = async () => { - try { - await updateMlSearchEnabled(false); - onClose(); - } catch (e) { - log.error("Disable ML search failed", e); - somethingWentWrong(); - } - }; - - const disableFaceSearch = async () => { - try { - startLoading(); - await updateFaceSearchEnabledStatus(false); - await disableMlSearch(); - finishLoading(); - } catch (e) { - log.error("Disable face search failed", e); - somethingWentWrong(); - } - }; - - const confirmDisableFaceSearch = () => { - setDialogMessage({ - title: t("DISABLE_FACE_SEARCH_TITLE"), - content: ( - - - - ), - close: { text: t("CANCEL") }, - proceed: { - variant: "primary", - text: t("DISABLE_FACE_SEARCH"), - action: disableFaceSearch, - }, - }); - }; - - const handleRootClose = () => { - onClose(); - onRootClose(); - }; - - const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { - if (reason === "backdropClick") { - handleRootClose(); - } else { - onClose(); - } - }; - - return ( - - - {mlSearchEnabled ? ( - - ) : ( - - )} - - - - - ); -}; - -export default MLSearchSettings; diff --git a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/manageMLSearch.tsx b/web/apps/photos/src/components/MachineLearning/MLSearchSettings/manageMLSearch.tsx deleted file mode 100644 index 15dacd7b25..0000000000 --- a/web/apps/photos/src/components/MachineLearning/MLSearchSettings/manageMLSearch.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Box, Stack } from "@mui/material"; -import { EnteMenuItem } from "components/Menu/EnteMenuItem"; -import { MenuItemGroup } from "components/Menu/MenuItemGroup"; -import Titlebar from "components/Titlebar"; -import { t } from "i18next"; - -export default function ManageMLSearch({ - onClose, - disableMlSearch, - handleDisableFaceSearch, - onRootClose, -}) { - return ( - - - - - - - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx index 24de200c04..34fdb8e34a 100644 --- a/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/index.tsx @@ -10,11 +10,8 @@ import TextSnippetOutlined from "@mui/icons-material/TextSnippetOutlined"; import { Box, DialogProps, Link, Stack, styled } from "@mui/material"; import { Chip } from "components/Chip"; import { EnteDrawer } from "components/EnteDrawer"; -import { - PhotoPeopleList, - UnidentifiedFaces, -} from "components/MachineLearning/PeopleList"; import Titlebar from "components/Titlebar"; +import { PhotoPeopleList, UnidentifiedFaces } from "components/ml/PeopleList"; import LinkButton from "components/pages/gallery/LinkButton"; import { t } from "i18next"; import { AppContext } from "pages/_app"; diff --git a/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx b/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx index 89bdce56ac..6ebc0d9422 100644 --- a/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx +++ b/web/apps/photos/src/components/Search/SearchBar/searchInput/MenuWithPeople.tsx @@ -1,6 +1,6 @@ import { Row } from "@ente/shared/components/Container"; import { Box, styled } from "@mui/material"; -import { PeopleList } from "components/MachineLearning/PeopleList"; +import { PeopleList } from "components/ml/PeopleList"; import { t } from "i18next"; import { AppContext } from "pages/_app"; import { useContext } from "react"; diff --git a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx index 817aecb2bf..6972cc1613 100644 --- a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx +++ b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx @@ -3,9 +3,9 @@ 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 MLSearchSettings from "components/MachineLearning/MLSearchSettings"; 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"; diff --git a/web/apps/photos/src/components/ml/MLSearchSettings.tsx b/web/apps/photos/src/components/ml/MLSearchSettings.tsx new file mode 100644 index 0000000000..583b79529c --- /dev/null +++ b/web/apps/photos/src/components/ml/MLSearchSettings.tsx @@ -0,0 +1,327 @@ +import log from "@/next/log"; +import { + Box, + Button, + Checkbox, + DialogProps, + FormControlLabel, + FormGroup, + Link, + Stack, + Typography, +} from "@mui/material"; +import { EnteDrawer } from "components/EnteDrawer"; +import { EnteMenuItem } from "components/Menu/EnteMenuItem"; +import { MenuItemGroup } from "components/Menu/MenuItemGroup"; +import Titlebar from "components/Titlebar"; +import { t } from "i18next"; +import { AppContext } from "pages/_app"; +import { useContext, useEffect, useState } from "react"; +import { Trans } from "react-i18next"; +import { + getFaceSearchEnabledStatus, + updateFaceSearchEnabledStatus, +} from "services/userService"; +import { openLink } from "utils/common"; + +export const MLSearchSettings = ({ open, onClose, onRootClose }) => { + const { + updateMlSearchEnabled, + mlSearchEnabled, + setDialogMessage, + somethingWentWrong, + startLoading, + finishLoading, + } = useContext(AppContext); + + const [enableFaceSearchView, setEnableFaceSearchView] = useState(false); + + const openEnableFaceSearch = () => { + setEnableFaceSearchView(true); + }; + const closeEnableFaceSearch = () => { + setEnableFaceSearchView(false); + }; + + const enableMlSearch = async () => { + try { + const hasEnabledFaceSearch = await getFaceSearchEnabledStatus(); + if (!hasEnabledFaceSearch) { + openEnableFaceSearch(); + } else { + updateMlSearchEnabled(true); + } + } catch (e) { + log.error("Enable ML search failed", e); + somethingWentWrong(); + } + }; + + const enableFaceSearch = async () => { + try { + startLoading(); + await updateFaceSearchEnabledStatus(true); + updateMlSearchEnabled(true); + closeEnableFaceSearch(); + finishLoading(); + } catch (e) { + log.error("Enable face search failed", e); + somethingWentWrong(); + } + }; + + const disableMlSearch = async () => { + try { + await updateMlSearchEnabled(false); + onClose(); + } catch (e) { + log.error("Disable ML search failed", e); + somethingWentWrong(); + } + }; + + const disableFaceSearch = async () => { + try { + startLoading(); + await updateFaceSearchEnabledStatus(false); + await disableMlSearch(); + finishLoading(); + } catch (e) { + log.error("Disable face search failed", e); + somethingWentWrong(); + } + }; + + const confirmDisableFaceSearch = () => { + setDialogMessage({ + title: t("DISABLE_FACE_SEARCH_TITLE"), + content: ( + + + + ), + close: { text: t("CANCEL") }, + proceed: { + variant: "primary", + text: t("DISABLE_FACE_SEARCH"), + action: disableFaceSearch, + }, + }); + }; + + const handleRootClose = () => { + onClose(); + onRootClose(); + }; + + const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { + if (reason === "backdropClick") { + handleRootClose(); + } else { + onClose(); + } + }; + + return ( + + + {mlSearchEnabled ? ( + + ) : ( + + )} + + + + + ); +}; + +function EnableFaceSearch({ open, onClose, enableFaceSearch, onRootClose }) { + const [acceptTerms, setAcceptTerms] = useState(false); + + useEffect(() => { + setAcceptTerms(false); + }, [open]); + + const handleRootClose = () => { + onClose(); + onRootClose(); + }; + + const handleDrawerClose: DialogProps["onClose"] = (_, reason) => { + if (reason === "backdropClick") { + handleRootClose(); + } else { + onClose(); + } + }; + return ( + + + + + + + ), + }} + /> + + + + setAcceptTerms(e.target.checked) + } + /> + } + label={t("FACE_SEARCH_CONFIRMATION")} + /> + + + + + + + + + ); +} + +function EnableMLSearch({ onClose, enableMlSearch, onRootClose }) { + const showDetails = () => + openLink("https://ente.io/blog/desktop-ml-beta", true); + + return ( + + + + + {" "} + + + + + + + + + + + ); +} + +function ManageMLSearch({ + onClose, + disableMlSearch, + handleDisableFaceSearch, + onRootClose, +}) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/web/apps/photos/src/components/MachineLearning/PeopleList.tsx b/web/apps/photos/src/components/ml/PeopleList.tsx similarity index 66% rename from web/apps/photos/src/components/MachineLearning/PeopleList.tsx rename to web/apps/photos/src/components/ml/PeopleList.tsx index 0e358ce337..e92b02680a 100644 --- a/web/apps/photos/src/components/MachineLearning/PeopleList.tsx +++ b/web/apps/photos/src/components/ml/PeopleList.tsx @@ -1,17 +1,14 @@ +import { ensureLocalUser } from "@/next/local-user"; import log from "@/next/log"; -import { CACHES } from "@ente/shared/storage/cacheStorage/constants"; -import { styled } from "@mui/material"; +import { cached } from "@ente/shared/storage/cache"; +import { Skeleton, styled } from "@mui/material"; import { Legend } from "components/PhotoViewer/styledComponents/Legend"; import { t } from "i18next"; import React, { useEffect, useState } from "react"; +import machineLearningService from "services/machineLearning/machineLearningService"; import { EnteFile } from "types/file"; import { Face, Person } from "types/machineLearning"; -import { - getAllPeople, - getPeopleList, - getUnidentifiedFaces, -} from "utils/machineLearning"; -import { ImageCacheView } from "./ImageViews"; +import { getPeopleList, getUnidentifiedFaces } from "utils/machineLearning"; const FaceChipContainer = styled("div")` display: flex; @@ -63,10 +60,9 @@ export const PeopleList = React.memo((props: PeopleListProps) => { props.onSelect && props.onSelect(person, index) } > - ))} @@ -111,36 +107,6 @@ export function PhotoPeopleList(props: PhotoPeopleListProps) { ); } -export interface AllPeopleListProps extends PeopleListPropsBase { - limit?: number; -} - -export function AllPeopleList(props: AllPeopleListProps) { - const [people, setPeople] = useState>([]); - - useEffect(() => { - let didCancel = false; - - async function updateFaceImages() { - try { - let people = await getAllPeople(); - if (props.limit) { - people = people.slice(0, props.limit); - } - !didCancel && setPeople(people); - } catch (e) { - log.error("updateFaceImages failed", e); - } - } - updateFaceImages(); - return () => { - didCancel = true; - }; - }, [props.limit]); - - return ; -} - export function UnidentifiedFaces(props: { file: EnteFile; updateMLDataIndex: number; @@ -173,10 +139,9 @@ export function UnidentifiedFaces(props: { {faces && faces.map((face, index) => ( - ))} @@ -184,3 +149,62 @@ export function UnidentifiedFaces(props: { ); } + +interface FaceCropImageViewProps { + url: string; + faceId: string; +} + +const FaceCropImageView: React.FC = ({ + url, + faceId, +}) => { + const [objectURL, setObjectURL] = useState(); + + useEffect(() => { + let didCancel = false; + + async function loadImage() { + let blob: Blob; + if (!url) { + blob = undefined; + } else { + const user = await ensureLocalUser(); + blob = await cached("face-crops", url, async () => { + try { + log.debug( + () => + `ImageCacheView: regenerate face crop for ${faceId}`, + ); + return machineLearningService.regenerateFaceCrop( + user.token, + user.id, + faceId, + ); + } catch (e) { + log.error( + "ImageCacheView: regenerate face crop failed", + e, + ); + } + }); + } + + if (didCancel) return; + setObjectURL(blob ? URL.createObjectURL(blob) : undefined); + } + + loadImage(); + + return () => { + didCancel = true; + if (objectURL) URL.revokeObjectURL(objectURL); + }; + }, [url, faceId]); + + return objectURL ? ( + + ) : ( + + ); +}; diff --git a/web/apps/photos/src/services/download/index.ts b/web/apps/photos/src/services/download/index.ts index d262c74aac..1c9919fbee 100644 --- a/web/apps/photos/src/services/download/index.ts +++ b/web/apps/photos/src/services/download/index.ts @@ -4,9 +4,10 @@ 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 { CacheStorageService } from "@ente/shared/storage/cacheStorage"; -import { CACHES } from "@ente/shared/storage/cacheStorage/constants"; -import { LimitedCache } from "@ente/shared/storage/cacheStorage/types"; +import { + CacheStorageService, + type LimitedCache, +} from "@ente/shared/storage/cache"; import { Remote } from "comlink"; import { FILE_TYPE } from "constants/file"; import isElectron from "is-electron"; @@ -516,7 +517,7 @@ export default DownloadManager; async function openThumbnailCache() { try { - return await CacheStorageService.open(CACHES.THUMBS); + return await CacheStorageService.open("thumbs"); } catch (e) { log.error("Failed to open thumbnail cache", e); if (isInternalUser()) { @@ -532,7 +533,7 @@ async function openDiskFileCache() { if (!isElectron()) { throw Error(CustomError.NOT_AVAILABLE_ON_WEB); } - return await CacheStorageService.open(CACHES.FILES); + return await CacheStorageService.open("files"); } catch (e) { log.error("Failed to open file cache", e); if (isInternalUser()) { diff --git a/web/apps/photos/src/utils/machineLearning/faceCrop.ts b/web/apps/photos/src/utils/machineLearning/faceCrop.ts index 5486c64488..177b2a8ec1 100644 --- a/web/apps/photos/src/utils/machineLearning/faceCrop.ts +++ b/web/apps/photos/src/utils/machineLearning/faceCrop.ts @@ -1,5 +1,4 @@ -import { CacheStorageService } from "@ente/shared/storage/cacheStorage"; -import { CACHES } from "@ente/shared/storage/cacheStorage/constants"; +import { CacheStorageService } from "@ente/shared/storage/cache"; import { BlobOptions } from "types/image"; import { FaceAlignment, @@ -55,7 +54,7 @@ async function storeFaceCropForBlob( ) { const faceCropUrl = `/${faceId}`; const faceCropResponse = new Response(faceCropBlob); - const faceCropCache = await CacheStorageService.open(CACHES.FACE_CROPS); + const faceCropCache = await CacheStorageService.open("face-crops"); await faceCropCache.put(faceCropUrl, faceCropResponse); return { imageUrl: faceCropUrl, diff --git a/web/apps/photos/src/utils/machineLearning/index.ts b/web/apps/photos/src/utils/machineLearning/index.ts index d0eacf1a6e..95e1018c9c 100644 --- a/web/apps/photos/src/utils/machineLearning/index.ts +++ b/web/apps/photos/src/utils/machineLearning/index.ts @@ -1,6 +1,5 @@ import log from "@/next/log"; -import { CACHES } from "@ente/shared/storage/cacheStorage/constants"; -import { cached } from "@ente/shared/storage/cacheStorage/helpers"; +import { cached } from "@ente/shared/storage/cache"; import { FILE_TYPE } from "constants/file"; import PQueue from "p-queue"; import DownloadManager from "services/download"; @@ -152,7 +151,7 @@ export async function getOriginalImageBitmap( let fileBlob; if (useCache) { - fileBlob = await cached(CACHES.FILES, file.id.toString(), () => { + fileBlob = await cached("files", file.id.toString(), () => { return getOriginalConvertedFile(file, queue); }); } else { diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 87a320e361..777709c3a3 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -1,7 +1,7 @@ import log from "@/next/log"; import { Events, eventBus } from "@ente/shared/events"; import InMemoryStore from "@ente/shared/storage/InMemoryStore"; -import { deleteAllCache } from "@ente/shared/storage/cacheStorage/helpers"; +import { clearCaches } from "@ente/shared/storage/cache"; import { clearFiles } from "@ente/shared/storage/localForage/helpers"; import { clearData } from "@ente/shared/storage/localStorage"; import { clearKeys } from "@ente/shared/storage/sessionStorage"; @@ -31,7 +31,7 @@ export const logoutUser = async () => { log.error("Ignoring error when clearing data", e); } try { - await deleteAllCache(); + await clearCaches(); } catch (e) { log.error("Ignoring error when clearing caches", e); } diff --git a/web/packages/next/local-user.ts b/web/packages/next/local-user.ts new file mode 100644 index 0000000000..d20bb7781d --- /dev/null +++ b/web/packages/next/local-user.ts @@ -0,0 +1,42 @@ +// TODO: This file belongs to the accounts package +import * as yup from "yup"; + +const localUserSchema = yup.object({ + /** The user's ID. */ + id: yup.number().required(), + /** The user's email. */ + email: yup.string().required(), + /** + * The user's (plaintext) auth token. + * + * It is used for making API calls on their behalf. + */ + token: yup.string().required(), +}); + +/** Locally available data for the logged in user's */ +export type LocalUser = yup.InferType; + +/** + * Return the logged-in user (if someone is indeed logged in). + * + * The user's data is stored in the browser's localStorage. + */ +export const localUser = async (): Promise => { + // TODO(MR): duplicate of LS_KEYS.USER + const s = localStorage.getItem("user"); + if (!s) return undefined; + return await localUserSchema.validate(JSON.parse(s), { + strict: true, + }); +}; + +/** + * A wrapper over {@link localUser} with that throws if no one is logged in. + */ +export const ensureLocalUser = async (): Promise => { + const user = await localUser(); + if (!user) + throw new Error("Attempting to access user data when not logged in"); + return user; +}; diff --git a/web/packages/next/worker/comlink-worker.ts b/web/packages/next/worker/comlink-worker.ts index f082ac1145..a5237fccc5 100644 --- a/web/packages/next/worker/comlink-worker.ts +++ b/web/packages/next/worker/comlink-worker.ts @@ -1,6 +1,7 @@ import { ensureElectron } from "@/next/electron"; import log, { logToDisk } from "@/next/log"; import { expose, wrap, type Remote } from "comlink"; +import { ensureLocalUser } from "../local-user"; export class ComlinkWorker InstanceType> { public remote: Promise>>; @@ -35,29 +36,20 @@ export class ComlinkWorker InstanceType> { } } -// TODO(MR): Temporary method to forward auth tokens to workers -const getAuthToken = () => { - // LS_KEYS.USER - const userJSONString = localStorage.getItem("user"); - if (!userJSONString) return undefined; - const json: unknown = JSON.parse(userJSONString); - if (!json || typeof json != "object" || !("token" in json)) - return undefined; - const token = json.token; - if (typeof token != "string") return undefined; - return token; -}; - /** - * A minimal set of utility functions that we expose to all workers that we - * create. + * A set of utility functions that we expose to all workers that we create. * * Inside the worker's code, this can be accessed by using the sibling * `workerBridge` object after importing it from `worker-bridge.ts`. + * + * Not all workers need access to all these functions, and this can indeed be + * done in a more fine-grained, per-worker, manner if needed. */ const workerBridge = { + // Needed: generally (presumably) logToDisk, - getAuthToken, + // Needed by ML worker + getAuthToken: () => ensureLocalUser().then((user) => user.token), convertToJPEG: (inputFileData: Uint8Array, filename: string) => ensureElectron().convertToJPEG(inputFileData, filename), detectFaces: (input: Float32Array) => ensureElectron().detectFaces(input), diff --git a/web/packages/shared/storage/cache.ts b/web/packages/shared/storage/cache.ts new file mode 100644 index 0000000000..ba26019b94 --- /dev/null +++ b/web/packages/shared/storage/cache.ts @@ -0,0 +1,66 @@ +const cacheNames = [ + "thumbs", + "face-crops", + // Desktop app only + "files", +] as const; + +/** Namespaces into which our caches data is divided */ +export type CacheName = (typeof cacheNames)[number]; + +export interface LimitedCache { + match: ( + key: string, + options?: { sizeInBytes?: number }, + ) => Promise; + put: (key: string, data: Response) => Promise; + delete: (key: string) => Promise; +} + +const openCache = async (name: CacheName) => { + const cache = await caches.open(name); + return { + match: (key) => { + // options are not supported in the browser + return cache.match(key); + }, + put: cache.put.bind(cache), + delete: cache.delete.bind(cache), + }; +}; + +export const CacheStorageService = { open: openCache }; + +export async function cached( + cacheName: CacheName, + id: string, + get: () => Promise, +): Promise { + const cache = await CacheStorageService.open(cacheName); + const cacheResponse = await cache.match(id); + + let result: Blob; + if (cacheResponse) { + result = await cacheResponse.blob(); + } else { + result = await get(); + + try { + await cache.put(id, new Response(result)); + } catch (e) { + // TODO: handle storage full exception. + console.error("Error while storing file to cache: ", id); + } + } + + return result; +} + +/** + * Delete all cached data. + * + * Meant for use during logout, to reset the state of the user's account. + */ +export const clearCaches = async () => { + await Promise.all(cacheNames.map((name) => caches.delete(name))); +}; diff --git a/web/packages/shared/storage/cacheStorage/constants.ts b/web/packages/shared/storage/cacheStorage/constants.ts deleted file mode 100644 index 4a31b78972..0000000000 --- a/web/packages/shared/storage/cacheStorage/constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum CACHES { - THUMBS = "thumbs", - FACE_CROPS = "face-crops", - // Desktop app only - FILES = "files", -} diff --git a/web/packages/shared/storage/cacheStorage/factory.ts b/web/packages/shared/storage/cacheStorage/factory.ts deleted file mode 100644 index 0de07aeec0..0000000000 --- a/web/packages/shared/storage/cacheStorage/factory.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { LimitedCacheStorage } from "./types"; - -class cacheStorageFactory { - getCacheStorage(): LimitedCacheStorage { - return transformBrowserCacheStorageToLimitedCacheStorage(caches); - } -} - -export const CacheStorageFactory = new cacheStorageFactory(); - -function transformBrowserCacheStorageToLimitedCacheStorage( - caches: CacheStorage, -): LimitedCacheStorage { - return { - async open(cacheName) { - const cache = await caches.open(cacheName); - return { - match: (key) => { - // options are not supported in the browser - return cache.match(key); - }, - put: cache.put.bind(cache), - delete: cache.delete.bind(cache), - }; - }, - delete: caches.delete.bind(caches), - }; -} diff --git a/web/packages/shared/storage/cacheStorage/helpers.ts b/web/packages/shared/storage/cacheStorage/helpers.ts deleted file mode 100644 index b306d56837..0000000000 --- a/web/packages/shared/storage/cacheStorage/helpers.ts +++ /dev/null @@ -1,55 +0,0 @@ -import log from "@/next/log"; -import { CacheStorageService } from "."; -import { CACHES } from "./constants"; -import { LimitedCache } from "./types"; - -export async function cached( - cacheName: string, - id: string, - get: () => Promise, -): Promise { - const cache = await CacheStorageService.open(cacheName); - const cacheResponse = await cache.match(id); - - let result: Blob; - if (cacheResponse) { - result = await cacheResponse.blob(); - } else { - result = await get(); - - try { - await cache.put(id, new Response(result)); - } catch (e) { - // TODO: handle storage full exception. - console.error("Error while storing file to cache: ", id); - } - } - - return result; -} - -let thumbCache: LimitedCache; - -export async function getBlobFromCache( - cacheName: string, - url: string, -): Promise { - if (!thumbCache) { - thumbCache = await CacheStorageService.open(cacheName); - } - const response = await thumbCache.match(url); - if (!response) { - return undefined; - } - return response.blob(); -} - -export async function deleteAllCache() { - try { - await CacheStorageService.delete(CACHES.THUMBS); - await CacheStorageService.delete(CACHES.FACE_CROPS); - await CacheStorageService.delete(CACHES.FILES); - } catch (e) { - log.error("deleteAllCache failed", e); // log and ignore - } -} diff --git a/web/packages/shared/storage/cacheStorage/index.ts b/web/packages/shared/storage/cacheStorage/index.ts deleted file mode 100644 index a70bf09272..0000000000 --- a/web/packages/shared/storage/cacheStorage/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import log from "@/next/log"; -import { CacheStorageFactory } from "./factory"; - -const SecurityError = "SecurityError"; -const INSECURE_OPERATION = "The operation is insecure."; -async function openCache(cacheName: string, cacheLimit?: number) { - try { - return await CacheStorageFactory.getCacheStorage().open( - cacheName, - cacheLimit, - ); - } catch (e) { - // ignoring insecure operation error, as it is thrown in incognito mode in firefox - if (e.name === SecurityError && e.message === INSECURE_OPERATION) { - // no-op - } else { - // log and ignore, we don't want to break the caller flow, when cache is not available - log.error("openCache failed", e); - } - } -} -async function deleteCache(cacheName: string) { - try { - return await CacheStorageFactory.getCacheStorage().delete(cacheName); - } catch (e) { - // ignoring insecure operation error, as it is thrown in incognito mode in firefox - if (e.name === SecurityError && e.message === INSECURE_OPERATION) { - // no-op - } else { - // log and ignore, we don't want to break the caller flow, when cache is not available - log.error("deleteCache failed", e); - } - } -} - -export const CacheStorageService = { open: openCache, delete: deleteCache }; diff --git a/web/packages/shared/storage/cacheStorage/types.ts b/web/packages/shared/storage/cacheStorage/types.ts deleted file mode 100644 index d520559224..0000000000 --- a/web/packages/shared/storage/cacheStorage/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface LimitedCacheStorage { - open: ( - cacheName: string, - cacheLimitInBytes?: number, - ) => Promise; - delete: (cacheName: string) => Promise; -} - -export interface LimitedCache { - match: ( - key: string, - options?: { sizeInBytes?: number }, - ) => Promise; - put: (key: string, data: Response) => Promise; - delete: (key: string) => Promise; -}