diff --git a/auth/linux/packaging/appimage/make_config.yaml b/auth/linux/packaging/appimage/make_config.yaml index 8db2b97d92..90db9c5879 100644 --- a/auth/linux/packaging/appimage/make_config.yaml +++ b/auth/linux/packaging/appimage/make_config.yaml @@ -24,4 +24,5 @@ startup_notify: false # include: # - libcurl.so.4 include: + - libffi.so.7 - libtiff.so.5 diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index ed6b18e87d..2ef543aa69 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -1,6 +1,6 @@ name: ente_auth description: ente two-factor authenticator -version: 2.0.54+254 +version: 2.0.55+255 publish_to: none environment: diff --git a/desktop/build/ggmlclip-linux b/desktop/build/ggmlclip-linux deleted file mode 100755 index 4c160b0392..0000000000 Binary files a/desktop/build/ggmlclip-linux and /dev/null differ diff --git a/desktop/build/ggmlclip-mac b/desktop/build/ggmlclip-mac deleted file mode 100755 index db7c4f2495..0000000000 Binary files a/desktop/build/ggmlclip-mac and /dev/null differ diff --git a/desktop/build/ggmlclip-windows.exe b/desktop/build/ggmlclip-windows.exe deleted file mode 100755 index 1e197dfe82..0000000000 Binary files a/desktop/build/ggmlclip-windows.exe and /dev/null differ diff --git a/desktop/build/msvcp140d.dll b/desktop/build/msvcp140d.dll deleted file mode 100644 index 358e18663a..0000000000 Binary files a/desktop/build/msvcp140d.dll and /dev/null differ diff --git a/desktop/build/ucrtbased.dll b/desktop/build/ucrtbased.dll deleted file mode 100644 index 78cfcfeeb8..0000000000 Binary files a/desktop/build/ucrtbased.dll and /dev/null differ diff --git a/desktop/build/vcruntime140_1d.dll b/desktop/build/vcruntime140_1d.dll deleted file mode 100644 index 700cf5f75f..0000000000 Binary files a/desktop/build/vcruntime140_1d.dll and /dev/null differ diff --git a/desktop/build/vcruntime140d.dll b/desktop/build/vcruntime140d.dll deleted file mode 100644 index 8b5425e0f5..0000000000 Binary files a/desktop/build/vcruntime140d.dll and /dev/null differ diff --git a/desktop/docs/dependencies.md b/desktop/docs/dependencies.md index 103583a635..5c1b077449 100644 --- a/desktop/docs/dependencies.md +++ b/desktop/docs/dependencies.md @@ -111,11 +111,11 @@ watcher for the watch folders functionality. ### AI/ML -- [onnxruntime-node](https://github.com/Microsoft/onnxruntime) -- html-entities is used by the bundled clip-bpe-ts. -- GGML binaries are bundled -- We also use [jpeg-js](https://github.com/jpeg-js/jpeg-js#readme) for - conversion of all images to JPEG before processing. +- [onnxruntime-node](https://github.com/Microsoft/onnxruntime) is used for + natural language searches based on CLIP. +- html-entities is used by the bundled clip-bpe-ts tokenizer. +- [jpeg-js](https://github.com/jpeg-js/jpeg-js#readme) is used for decoding + JPEG data into raw RGB bytes before passing it to ONNX. ## ZIP diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 9189c34355..4fdfc4f55f 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -19,7 +19,6 @@ mac: arch: [universal] category: public.app-category.photography hardenedRuntime: true - x64ArchFiles: Contents/Resources/ggmlclip-mac afterSign: electron-builder-notarize extraFiles: - from: build diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 246b6da1bc..b1e89b40c6 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -27,7 +27,7 @@ import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc"; import log, { initLogging } from "./main/log"; import { createApplicationMenu } from "./main/menu"; import { isDev } from "./main/util"; -import { setupAutoUpdater } from "./services/appUpdater"; +import { setupAutoUpdater } from "./services/app-update"; import { initWatcher } from "./services/chokidar"; let appIsQuitting = false; @@ -142,9 +142,10 @@ const deleteLegacyDiskCacheDirIfExists = async () => { }; const attachEventHandlers = (mainWindow: BrowserWindow) => { - // Let ipcRenderer know when mainWindow is in the foreground. + // Let ipcRenderer know when mainWindow is in the foreground so that it can + // in turn inform the renderer process. mainWindow.on("focus", () => - mainWindow.webContents.send("app-in-foreground"), + mainWindow.webContents.send("mainWindowFocus"), ); }; diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 0fdd10056a..ecb3e20106 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -12,14 +12,11 @@ import type { FSWatcher } from "chokidar"; import { ipcMain } from "electron/main"; import { appVersion, - muteUpdateNotification, skipAppUpdate, updateAndRestart, -} from "../services/appUpdater"; -import { - computeImageEmbedding, - computeTextEmbedding, -} from "../services/clipService"; + updateOnNextRestart, +} from "../services/app-update"; +import { clipImageEmbedding, clipTextEmbedding } from "../services/clip"; import { runFFmpegCmd } from "../services/ffmpeg"; import { getDirFiles } from "../services/fs"; import { @@ -27,9 +24,9 @@ import { generateImageThumbnail, } from "../services/imageProcessor"; import { - clearElectronStore, - getEncryptionKey, - setEncryptionKey, + clearStores, + encryptionKey, + saveEncryptionKey, } from "../services/store"; import { getElectronFilesFromGoogleZip, @@ -44,12 +41,7 @@ import { updateWatchMappingIgnoredFiles, updateWatchMappingSyncedFiles, } from "../services/watch"; -import type { - ElectronFile, - FILE_PATH_TYPE, - Model, - WatchMapping, -} from "../types/ipc"; +import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc"; import { selectDirectory, showUploadDirsDialog, @@ -103,26 +95,24 @@ export const attachIPCHandlers = () => { // See [Note: Catching exception during .send/.on] ipcMain.on("logToDisk", (_, message) => logToDisk(message)); - ipcMain.on("clear-electron-store", () => { - clearElectronStore(); - }); + ipcMain.on("clearStores", () => clearStores()); - ipcMain.handle("setEncryptionKey", (_, encryptionKey) => - setEncryptionKey(encryptionKey), + ipcMain.handle("saveEncryptionKey", (_, encryptionKey) => + saveEncryptionKey(encryptionKey), ); - ipcMain.handle("getEncryptionKey", () => getEncryptionKey()); + ipcMain.handle("encryptionKey", () => encryptionKey()); // - App update - ipcMain.on("update-and-restart", () => updateAndRestart()); + ipcMain.on("updateAndRestart", () => updateAndRestart()); - ipcMain.on("skip-app-update", (_, version) => skipAppUpdate(version)); - - ipcMain.on("mute-update-notification", (_, version) => - muteUpdateNotification(version), + ipcMain.on("updateOnNextRestart", (_, version) => + updateOnNextRestart(version), ); + ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version)); + // - Conversion ipcMain.handle("convertToJPEG", (_, fileData, filename) => @@ -148,14 +138,12 @@ export const attachIPCHandlers = () => { // - ML - ipcMain.handle( - "computeImageEmbedding", - (_, model: Model, imageData: Uint8Array) => - computeImageEmbedding(model, imageData), + ipcMain.handle("clipImageEmbedding", (_, jpegImageData: Uint8Array) => + clipImageEmbedding(jpegImageData), ); - ipcMain.handle("computeTextEmbedding", (_, model: Model, text: string) => - computeTextEmbedding(model, text), + ipcMain.handle("clipTextEmbedding", (_, text: string) => + clipTextEmbedding(text), ); // - File selection diff --git a/desktop/src/main/log.ts b/desktop/src/main/log.ts index 0e504115c1..d43161feaf 100644 --- a/desktop/src/main/log.ts +++ b/desktop/src/main/log.ts @@ -19,6 +19,16 @@ export const initLogging = () => { log.transports.file.format = "[{y}-{m}-{d}T{h}:{i}:{s}{z}] {text}"; log.transports.console.level = false; + + // Log unhandled errors and promise rejections. + log.errorHandler.startCatching({ + onError: ({ error, errorName }) => { + logError(errorName, error); + // Prevent the default electron-log actions (e.g. showing a dialog) + // from getting triggered. + return false; + }, + }); }; /** @@ -64,7 +74,10 @@ const logInfo = (...params: any[]) => { }; const logDebug = (param: () => any) => { - if (isDev) console.log(`[debug] ${util.inspect(param())}`); + if (isDev) { + const p = param(); + console.log(`[debug] ${typeof p == "string" ? p : util.inspect(p)}`); + } }; /** diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts index 6589329611..c6ac1688a3 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/menu.ts @@ -6,7 +6,7 @@ import { shell, } from "electron"; import { setIsAppQuitting } from "../main"; -import { forceCheckForUpdateAndNotify } from "../services/appUpdater"; +import { forceCheckForAppUpdates } from "../services/app-update"; import autoLauncher from "../services/autoLauncher"; import { getHideDockIconPreference, @@ -26,8 +26,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => { const macOSOnly = (options: MenuItemConstructorOptions[]) => process.platform == "darwin" ? options : []; - const handleCheckForUpdates = () => - forceCheckForUpdateAndNotify(mainWindow); + const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow); const handleViewChangelog = () => shell.openExternal( diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index aa528b7ad0..07736502bd 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -45,7 +45,6 @@ import type { AppUpdateInfo, ElectronFile, FILE_PATH_TYPE, - Model, WatchMapping, } from "./types/ipc"; @@ -53,58 +52,55 @@ import type { const appVersion = (): Promise => ipcRenderer.invoke("appVersion"); +const logToDisk = (message: string): void => + ipcRenderer.send("logToDisk", message); + const openDirectory = (dirPath: string): Promise => ipcRenderer.invoke("openDirectory", dirPath); const openLogDirectory = (): Promise => ipcRenderer.invoke("openLogDirectory"); -const logToDisk = (message: string): void => - ipcRenderer.send("logToDisk", message); +const clearStores = () => ipcRenderer.send("clearStores"); + +const encryptionKey = (): Promise => + ipcRenderer.invoke("encryptionKey"); + +const saveEncryptionKey = (encryptionKey: string): Promise => + ipcRenderer.invoke("saveEncryptionKey", encryptionKey); + +const onMainWindowFocus = (cb?: () => void) => { + ipcRenderer.removeAllListeners("mainWindowFocus"); + if (cb) ipcRenderer.on("mainWindowFocus", cb); +}; + +// - App update + +const onAppUpdateAvailable = ( + cb?: ((updateInfo: AppUpdateInfo) => void) | undefined, +) => { + ipcRenderer.removeAllListeners("appUpdateAvailable"); + if (cb) { + ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) => + cb(updateInfo), + ); + } +}; + +const updateAndRestart = () => ipcRenderer.send("updateAndRestart"); + +const updateOnNextRestart = (version: string) => + ipcRenderer.send("updateOnNextRestart", version); + +const skipAppUpdate = (version: string) => { + ipcRenderer.send("skipAppUpdate", version); +}; const fsExists = (path: string): Promise => ipcRenderer.invoke("fsExists", path); // - AUDIT below this -const registerForegroundEventListener = (onForeground: () => void) => { - ipcRenderer.removeAllListeners("app-in-foreground"); - ipcRenderer.on("app-in-foreground", onForeground); -}; - -const clearElectronStore = () => { - ipcRenderer.send("clear-electron-store"); -}; - -const setEncryptionKey = (encryptionKey: string): Promise => - ipcRenderer.invoke("setEncryptionKey", encryptionKey); - -const getEncryptionKey = (): Promise => - ipcRenderer.invoke("getEncryptionKey"); - -// - App update - -const registerUpdateEventListener = ( - showUpdateDialog: (updateInfo: AppUpdateInfo) => void, -) => { - ipcRenderer.removeAllListeners("show-update-dialog"); - ipcRenderer.on("show-update-dialog", (_, updateInfo: AppUpdateInfo) => { - showUpdateDialog(updateInfo); - }); -}; - -const updateAndRestart = () => { - ipcRenderer.send("update-and-restart"); -}; - -const skipAppUpdate = (version: string) => { - ipcRenderer.send("skip-app-update", version); -}; - -const muteUpdateNotification = (version: string) => { - ipcRenderer.send("mute-update-notification", version); -}; - // - Conversion const convertToJPEG = ( @@ -141,17 +137,11 @@ const runFFmpegCmd = ( // - ML -const computeImageEmbedding = ( - model: Model, - imageData: Uint8Array, -): Promise => - ipcRenderer.invoke("computeImageEmbedding", model, imageData); +const clipImageEmbedding = (jpegImageData: Uint8Array): Promise => + ipcRenderer.invoke("clipImageEmbedding", jpegImageData); -const computeTextEmbedding = ( - model: Model, - text: string, -): Promise => - ipcRenderer.invoke("computeTextEmbedding", model, text); +const clipTextEmbedding = (text: string): Promise => + ipcRenderer.invoke("clipTextEmbedding", text); // - File selection @@ -310,21 +300,19 @@ const getDirFiles = (dirPath: string): Promise => contextBridge.exposeInMainWorld("electron", { // - General appVersion, - openDirectory, - registerForegroundEventListener, - clearElectronStore, - getEncryptionKey, - setEncryptionKey, - - // - Logging - openLogDirectory, logToDisk, + openDirectory, + openLogDirectory, + clearStores, + encryptionKey, + saveEncryptionKey, + onMainWindowFocus, // - App update + onAppUpdateAvailable, updateAndRestart, + updateOnNextRestart, skipAppUpdate, - muteUpdateNotification, - registerUpdateEventListener, // - Conversion convertToJPEG, @@ -332,8 +320,8 @@ contextBridge.exposeInMainWorld("electron", { runFFmpegCmd, // - ML - computeImageEmbedding, - computeTextEmbedding, + clipImageEmbedding, + clipTextEmbedding, // - File selection selectDirectory, diff --git a/desktop/src/services/app-update.ts b/desktop/src/services/app-update.ts new file mode 100644 index 0000000000..ec592095e4 --- /dev/null +++ b/desktop/src/services/app-update.ts @@ -0,0 +1,98 @@ +import { compareVersions } from "compare-versions"; +import { app, BrowserWindow } from "electron"; +import { default as electronLog } from "electron-log"; +import { autoUpdater } from "electron-updater"; +import { setIsAppQuitting, setIsUpdateAvailable } from "../main"; +import log from "../main/log"; +import { userPreferencesStore } from "../stores/user-preferences"; +import { AppUpdateInfo } from "../types/ipc"; + +export const setupAutoUpdater = (mainWindow: BrowserWindow) => { + autoUpdater.logger = electronLog; + autoUpdater.autoDownload = false; + + const oneDay = 1 * 24 * 60 * 60 * 1000; + setInterval(() => checkForUpdatesAndNotify(mainWindow), oneDay); + checkForUpdatesAndNotify(mainWindow); +}; + +/** + * Check for app update check ignoring any previously saved skips / mutes. + */ +export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => { + userPreferencesStore.delete("skipAppVersion"); + userPreferencesStore.delete("muteUpdateNotificationVersion"); + checkForUpdatesAndNotify(mainWindow); +}; + +const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => { + try { + const { updateInfo } = await autoUpdater.checkForUpdates(); + const { version } = updateInfo; + + log.debug(() => `Checking for updates found version ${version}`); + + if (compareVersions(version, app.getVersion()) <= 0) { + log.debug(() => "Skipping update, already at latest version"); + return; + } + + if (version === userPreferencesStore.get("skipAppVersion")) { + log.info(`User chose to skip version ${version}`); + return; + } + + const mutedVersion = userPreferencesStore.get( + "muteUpdateNotificationVersion", + ); + if (version === mutedVersion) { + log.info( + `User has muted update notifications for version ${version}`, + ); + return; + } + + const showUpdateDialog = (updateInfo: AppUpdateInfo) => + mainWindow.webContents.send("appUpdateAvailable", updateInfo); + + log.debug(() => "Attempting auto update"); + autoUpdater.downloadUpdate(); + + let timeout: NodeJS.Timeout; + const fiveMinutes = 5 * 60 * 1000; + autoUpdater.on("update-downloaded", () => { + timeout = setTimeout( + () => showUpdateDialog({ autoUpdatable: true, version }), + fiveMinutes, + ); + }); + autoUpdater.on("error", (error) => { + clearTimeout(timeout); + log.error("Auto update failed", error); + showUpdateDialog({ autoUpdatable: false, version }); + }); + + setIsUpdateAvailable(true); + } catch (e) { + log.error("checkForUpdateAndNotify failed", e); + } +}; + +/** + * Return the version of the desktop app + * + * The return value is of the form `v1.2.3`. + */ +export const appVersion = () => `v${app.getVersion()}`; + +export const updateAndRestart = () => { + log.info("Restarting the app to apply update"); + setIsAppQuitting(true); + autoUpdater.quitAndInstall(); +}; + +export const updateOnNextRestart = (version: string) => + userPreferencesStore.set("muteUpdateNotificationVersion", version); + +export const skipAppUpdate = (version: string) => + userPreferencesStore.set("skipAppVersion", version); diff --git a/desktop/src/services/appUpdater.ts b/desktop/src/services/appUpdater.ts deleted file mode 100644 index 517fc98e9a..0000000000 --- a/desktop/src/services/appUpdater.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { compareVersions } from "compare-versions"; -import { app, BrowserWindow } from "electron"; -import { default as electronLog } from "electron-log"; -import { autoUpdater } from "electron-updater"; -import { setIsAppQuitting, setIsUpdateAvailable } from "../main"; -import log from "../main/log"; -import { AppUpdateInfo } from "../types/ipc"; -import { - clearMuteUpdateNotificationVersion, - clearSkipAppVersion, - getMuteUpdateNotificationVersion, - getSkipAppVersion, - setMuteUpdateNotificationVersion, - setSkipAppVersion, -} from "./userPreference"; - -const FIVE_MIN_IN_MICROSECOND = 5 * 60 * 1000; -const ONE_DAY_IN_MICROSECOND = 1 * 24 * 60 * 60 * 1000; - -export function setupAutoUpdater(mainWindow: BrowserWindow) { - autoUpdater.logger = electronLog; - autoUpdater.autoDownload = false; - checkForUpdateAndNotify(mainWindow); - setInterval( - () => checkForUpdateAndNotify(mainWindow), - ONE_DAY_IN_MICROSECOND, - ); -} - -export function forceCheckForUpdateAndNotify(mainWindow: BrowserWindow) { - try { - clearSkipAppVersion(); - clearMuteUpdateNotificationVersion(); - checkForUpdateAndNotify(mainWindow); - } catch (e) { - log.error("forceCheckForUpdateAndNotify failed", e); - } -} - -async function checkForUpdateAndNotify(mainWindow: BrowserWindow) { - try { - log.debug(() => "checkForUpdateAndNotify"); - const { updateInfo } = await autoUpdater.checkForUpdates(); - log.debug(() => `Update version ${updateInfo.version}`); - if (compareVersions(updateInfo.version, app.getVersion()) <= 0) { - log.debug(() => "Skipping update, already at latest version"); - return; - } - const skipAppVersion = getSkipAppVersion(); - if (skipAppVersion && updateInfo.version === skipAppVersion) { - log.info(`User chose to skip version ${updateInfo.version}`); - return; - } - - let timeout: NodeJS.Timeout; - log.debug(() => "Attempting auto update"); - autoUpdater.downloadUpdate(); - const muteUpdateNotificationVersion = - getMuteUpdateNotificationVersion(); - if ( - muteUpdateNotificationVersion && - updateInfo.version === muteUpdateNotificationVersion - ) { - log.info( - `User has muted update notifications for version ${updateInfo.version}`, - ); - return; - } - autoUpdater.on("update-downloaded", () => { - timeout = setTimeout( - () => - showUpdateDialog(mainWindow, { - autoUpdatable: true, - version: updateInfo.version, - }), - FIVE_MIN_IN_MICROSECOND, - ); - }); - autoUpdater.on("error", (error) => { - clearTimeout(timeout); - log.error("Auto update failed", error); - showUpdateDialog(mainWindow, { - autoUpdatable: false, - version: updateInfo.version, - }); - }); - - setIsUpdateAvailable(true); - } catch (e) { - log.error("checkForUpdateAndNotify failed", e); - } -} - -export function updateAndRestart() { - log.info("user quit the app"); - setIsAppQuitting(true); - autoUpdater.quitAndInstall(); -} - -/** - * Return the version of the desktop app - * - * The return value is of the form `v1.2.3`. - */ -export const appVersion = () => `v${app.getVersion()}`; - -export function skipAppUpdate(version: string) { - setSkipAppVersion(version); -} - -export function muteUpdateNotification(version: string) { - setMuteUpdateNotificationVersion(version); -} - -function showUpdateDialog( - mainWindow: BrowserWindow, - updateInfo: AppUpdateInfo, -) { - mainWindow.webContents.send("show-update-dialog", updateInfo); -} diff --git a/desktop/src/services/clip.ts b/desktop/src/services/clip.ts new file mode 100644 index 0000000000..5de05e6014 --- /dev/null +++ b/desktop/src/services/clip.ts @@ -0,0 +1,288 @@ +/** + * @file Compute CLIP embeddings + * + * @see `web/apps/photos/src/services/clip-service.ts` for more details. This + * file implements the Node.js implementation of the actual embedding + * computation. By doing it in the Node.js layer, we can use the binary ONNX + * runtimes which are 10-20x faster than the WASM based web ones. + * + * The embeddings are computed using ONNX runtime. The model itself is not + * shipped with the app but is downloaded on demand. + */ +import { app, net } from "electron/main"; +import { existsSync } from "fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { writeStream } from "../main/fs"; +import log from "../main/log"; +import { CustomErrors } from "../types/ipc"; +import Tokenizer from "../utils/clip-bpe-ts/mod"; +import { generateTempFilePath } from "../utils/temp"; +import { deleteTempFile } from "./ffmpeg"; +const jpeg = require("jpeg-js"); +const ort = require("onnxruntime-node"); + +const textModelName = "clip-text-vit-32-uint8.onnx"; +const textModelByteSize = 64173509; // 61.2 MB + +const imageModelName = "clip-image-vit-32-float32.onnx"; +const imageModelByteSize = 351468764; // 335.2 MB + +/** Return the path where the given {@link modelName} is meant to be saved */ +const modelSavePath = (modelName: string) => + path.join(app.getPath("userData"), "models", modelName); + +const downloadModel = async (saveLocation: string, name: string) => { + // `mkdir -p` the directory where we want to save the model. + const saveDir = path.dirname(saveLocation); + await fs.mkdir(saveDir, { recursive: true }); + // Download + log.info(`Downloading CLIP model from ${name}`); + const url = `https://models.ente.io/${name}`; + const res = await net.fetch(url); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + // Save + await writeStream(saveLocation, res.body); + log.info(`Downloaded CLIP model ${name}`); +}; + +let activeImageModelDownload: Promise | undefined; + +const imageModelPathDownloadingIfNeeded = async () => { + try { + const modelPath = modelSavePath(imageModelName); + if (activeImageModelDownload) { + log.info("Waiting for CLIP image model download to finish"); + await activeImageModelDownload; + } else { + if (!existsSync(modelPath)) { + log.info("CLIP image model not found, downloading"); + activeImageModelDownload = downloadModel( + modelPath, + imageModelName, + ); + await activeImageModelDownload; + } else { + const localFileSize = (await fs.stat(modelPath)).size; + if (localFileSize !== imageModelByteSize) { + log.error( + `CLIP image model size ${localFileSize} does not match the expected size, downloading again`, + ); + activeImageModelDownload = downloadModel( + modelPath, + imageModelName, + ); + await activeImageModelDownload; + } + } + } + return modelPath; + } finally { + activeImageModelDownload = undefined; + } +}; + +let textModelDownloadInProgress = false; + +const textModelPathDownloadingIfNeeded = async () => { + if (textModelDownloadInProgress) + throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); + + const modelPath = modelSavePath(textModelName); + if (!existsSync(modelPath)) { + log.info("CLIP text model not found, downloading"); + textModelDownloadInProgress = true; + downloadModel(modelPath, textModelName) + .catch((e) => { + // log but otherwise ignore + log.error("CLIP text model download failed", e); + }) + .finally(() => { + textModelDownloadInProgress = false; + }); + throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); + } else { + const localFileSize = (await fs.stat(modelPath)).size; + if (localFileSize !== textModelByteSize) { + log.error( + `CLIP text model size ${localFileSize} does not match the expected size, downloading again`, + ); + textModelDownloadInProgress = true; + downloadModel(modelPath, textModelName) + .catch((e) => { + // log but otherwise ignore + log.error("CLIP text model download failed", e); + }) + .finally(() => { + textModelDownloadInProgress = false; + }); + throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); + } + } + + return modelPath; +}; + +const createInferenceSession = async (modelPath: string) => { + return await ort.InferenceSession.create(modelPath, { + intraOpNumThreads: 1, + enableCpuMemArena: false, + }); +}; + +let imageSessionPromise: Promise | undefined; + +const onnxImageSession = async () => { + if (!imageSessionPromise) { + imageSessionPromise = (async () => { + const modelPath = await imageModelPathDownloadingIfNeeded(); + return createInferenceSession(modelPath); + })(); + } + return imageSessionPromise; +}; + +let _textSession: any = null; + +const onnxTextSession = async () => { + if (!_textSession) { + const modelPath = await textModelPathDownloadingIfNeeded(); + _textSession = await createInferenceSession(modelPath); + } + return _textSession; +}; + +export const clipImageEmbedding = async (jpegImageData: Uint8Array) => { + const tempFilePath = await generateTempFilePath(""); + const imageStream = new Response(jpegImageData.buffer).body; + await writeStream(tempFilePath, imageStream); + try { + return await clipImageEmbedding_(tempFilePath); + } finally { + await deleteTempFile(tempFilePath); + } +}; + +const clipImageEmbedding_ = async (jpegFilePath: string) => { + const imageSession = await onnxImageSession(); + const t1 = Date.now(); + const rgbData = await getRGBData(jpegFilePath); + const feeds = { + input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]), + }; + const t2 = Date.now(); + const results = await imageSession.run(feeds); + log.debug( + () => + `CLIP image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`, + ); + const imageEmbedding = results["output"].data; // Float32Array + return normalizeEmbedding(imageEmbedding); +}; + +const getRGBData = async (jpegFilePath: string) => { + const jpegData = await fs.readFile(jpegFilePath); + const rawImageData = jpeg.decode(jpegData, { + useTArray: true, + formatAsRGBA: false, + }); + + const nx: number = rawImageData.width; + const ny: number = rawImageData.height; + const inputImage: Uint8Array = rawImageData.data; + + const nx2: number = 224; + const ny2: number = 224; + const totalSize: number = 3 * nx2 * ny2; + + const result: number[] = Array(totalSize).fill(0); + const scale: number = Math.max(nx, ny) / 224; + + const nx3: number = Math.round(nx / scale); + const ny3: number = Math.round(ny / scale); + + const mean: number[] = [0.48145466, 0.4578275, 0.40821073]; + const std: number[] = [0.26862954, 0.26130258, 0.27577711]; + + for (let y = 0; y < ny3; y++) { + for (let x = 0; x < nx3; x++) { + for (let c = 0; c < 3; c++) { + // Linear interpolation + const sx: number = (x + 0.5) * scale - 0.5; + const sy: number = (y + 0.5) * scale - 0.5; + + const x0: number = Math.max(0, Math.floor(sx)); + const y0: number = Math.max(0, Math.floor(sy)); + + const x1: number = Math.min(x0 + 1, nx - 1); + const y1: number = Math.min(y0 + 1, ny - 1); + + const dx: number = sx - x0; + const dy: number = sy - y0; + + const j00: number = 3 * (y0 * nx + x0) + c; + const j01: number = 3 * (y0 * nx + x1) + c; + const j10: number = 3 * (y1 * nx + x0) + c; + const j11: number = 3 * (y1 * nx + x1) + c; + + const v00: number = inputImage[j00]; + const v01: number = inputImage[j01]; + const v10: number = inputImage[j10]; + const v11: number = inputImage[j11]; + + const v0: number = v00 * (1 - dx) + v01 * dx; + const v1: number = v10 * (1 - dx) + v11 * dx; + + const v: number = v0 * (1 - dy) + v1 * dy; + + const v2: number = Math.min(Math.max(Math.round(v), 0), 255); + + // createTensorWithDataList is dumb compared to reshape and + // hence has to be given with one channel after another + const i: number = y * nx3 + x + (c % 3) * 224 * 224; + + result[i] = (v2 / 255 - mean[c]) / std[c]; + } + } + } + + return result; +}; + +const normalizeEmbedding = (embedding: Float32Array) => { + let normalization = 0; + for (let index = 0; index < embedding.length; index++) { + normalization += embedding[index] * embedding[index]; + } + const sqrtNormalization = Math.sqrt(normalization); + for (let index = 0; index < embedding.length; index++) { + embedding[index] = embedding[index] / sqrtNormalization; + } + return embedding; +}; + +let _tokenizer: Tokenizer = null; +const getTokenizer = () => { + if (!_tokenizer) { + _tokenizer = new Tokenizer(); + } + return _tokenizer; +}; + +export const clipTextEmbedding = async (text: string) => { + const imageSession = await onnxTextSession(); + const t1 = Date.now(); + const tokenizer = getTokenizer(); + const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text)); + const feeds = { + input: new ort.Tensor("int32", tokenizedText, [1, 77]), + }; + const t2 = Date.now(); + const results = await imageSession.run(feeds); + log.debug( + () => + `CLIP text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`, + ); + const textEmbedding = results["output"].data; + return normalizeEmbedding(textEmbedding); +}; diff --git a/desktop/src/services/clipService.ts b/desktop/src/services/clipService.ts deleted file mode 100644 index 32d4049122..0000000000 --- a/desktop/src/services/clipService.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { app, net } from "electron/main"; -import { existsSync } from "fs"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { writeStream } from "../main/fs"; -import log from "../main/log"; -import { execAsync, isDev } from "../main/util"; -import { CustomErrors, Model, isModel } from "../types/ipc"; -import Tokenizer from "../utils/clip-bpe-ts/mod"; -import { getPlatform } from "../utils/common/platform"; -import { generateTempFilePath } from "../utils/temp"; -import { deleteTempFile } from "./ffmpeg"; -const jpeg = require("jpeg-js"); - -const CLIP_MODEL_PATH_PLACEHOLDER = "CLIP_MODEL"; -const GGMLCLIP_PATH_PLACEHOLDER = "GGML_PATH"; -const INPUT_PATH_PLACEHOLDER = "INPUT"; - -const IMAGE_EMBEDDING_EXTRACT_CMD: string[] = [ - GGMLCLIP_PATH_PLACEHOLDER, - "-mv", - CLIP_MODEL_PATH_PLACEHOLDER, - "--image", - INPUT_PATH_PLACEHOLDER, -]; - -const TEXT_EMBEDDING_EXTRACT_CMD: string[] = [ - GGMLCLIP_PATH_PLACEHOLDER, - "-mt", - CLIP_MODEL_PATH_PLACEHOLDER, - "--text", - INPUT_PATH_PLACEHOLDER, -]; -const ort = require("onnxruntime-node"); - -const TEXT_MODEL_DOWNLOAD_URL = { - ggml: "https://models.ente.io/clip-vit-base-patch32_ggml-text-model-f16.gguf", - onnx: "https://models.ente.io/clip-text-vit-32-uint8.onnx", -}; -const IMAGE_MODEL_DOWNLOAD_URL = { - ggml: "https://models.ente.io/clip-vit-base-patch32_ggml-vision-model-f16.gguf", - onnx: "https://models.ente.io/clip-image-vit-32-float32.onnx", -}; - -const TEXT_MODEL_NAME = { - ggml: "clip-vit-base-patch32_ggml-text-model-f16.gguf", - onnx: "clip-text-vit-32-uint8.onnx", -}; -const IMAGE_MODEL_NAME = { - ggml: "clip-vit-base-patch32_ggml-vision-model-f16.gguf", - onnx: "clip-image-vit-32-float32.onnx", -}; - -const IMAGE_MODEL_SIZE_IN_BYTES = { - ggml: 175957504, // 167.8 MB - onnx: 351468764, // 335.2 MB -}; -const TEXT_MODEL_SIZE_IN_BYTES = { - ggml: 127853440, // 121.9 MB, - onnx: 64173509, // 61.2 MB -}; - -/** Return the path where the given {@link modelName} is meant to be saved */ -const getModelSavePath = (modelName: string) => - path.join(app.getPath("userData"), "models", modelName); - -async function downloadModel(saveLocation: string, url: string) { - // confirm that the save location exists - const saveDir = path.dirname(saveLocation); - await fs.mkdir(saveDir, { recursive: true }); - log.info("downloading clip model"); - const res = await net.fetch(url); - if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); - await writeStream(saveLocation, res.body); - log.info("clip model downloaded"); -} - -let imageModelDownloadInProgress: Promise = null; - -const getClipImageModelPath = async (type: "ggml" | "onnx") => { - try { - const modelSavePath = getModelSavePath(IMAGE_MODEL_NAME[type]); - if (imageModelDownloadInProgress) { - log.info("waiting for image model download to finish"); - await imageModelDownloadInProgress; - } else { - if (!existsSync(modelSavePath)) { - log.info("CLIP image model not found, downloading"); - imageModelDownloadInProgress = downloadModel( - modelSavePath, - IMAGE_MODEL_DOWNLOAD_URL[type], - ); - await imageModelDownloadInProgress; - } else { - const localFileSize = (await fs.stat(modelSavePath)).size; - if (localFileSize !== IMAGE_MODEL_SIZE_IN_BYTES[type]) { - log.info( - `CLIP image model size mismatch, downloading again got: ${localFileSize}`, - ); - imageModelDownloadInProgress = downloadModel( - modelSavePath, - IMAGE_MODEL_DOWNLOAD_URL[type], - ); - await imageModelDownloadInProgress; - } - } - } - return modelSavePath; - } finally { - imageModelDownloadInProgress = null; - } -}; - -let textModelDownloadInProgress: boolean = false; - -const getClipTextModelPath = async (type: "ggml" | "onnx") => { - const modelSavePath = getModelSavePath(TEXT_MODEL_NAME[type]); - if (textModelDownloadInProgress) { - throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); - } else { - if (!existsSync(modelSavePath)) { - log.info("CLIP text model not found, downloading"); - textModelDownloadInProgress = true; - downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type]) - .catch((e) => { - // log but otherwise ignore - log.error("CLIP text model download failed", e); - }) - .finally(() => { - textModelDownloadInProgress = false; - }); - throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); - } else { - const localFileSize = (await fs.stat(modelSavePath)).size; - if (localFileSize !== TEXT_MODEL_SIZE_IN_BYTES[type]) { - log.info( - `CLIP text model size mismatch, downloading again got: ${localFileSize}`, - ); - textModelDownloadInProgress = true; - downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type]) - .catch((e) => { - // log but otherwise ignore - log.error("CLIP text model download failed", e); - }) - .finally(() => { - textModelDownloadInProgress = false; - }); - throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING); - } - } - } - return modelSavePath; -}; - -function getGGMLClipPath() { - return isDev - ? path.join("./build", `ggmlclip-${getPlatform()}`) - : path.join(process.resourcesPath, `ggmlclip-${getPlatform()}`); -} - -async function createOnnxSession(modelPath: string) { - return await ort.InferenceSession.create(modelPath, { - intraOpNumThreads: 1, - enableCpuMemArena: false, - }); -} - -let onnxImageSessionPromise: Promise = null; - -async function getOnnxImageSession() { - if (!onnxImageSessionPromise) { - onnxImageSessionPromise = (async () => { - const clipModelPath = await getClipImageModelPath("onnx"); - return createOnnxSession(clipModelPath); - })(); - } - return onnxImageSessionPromise; -} - -let onnxTextSession: any = null; - -async function getOnnxTextSession() { - if (!onnxTextSession) { - const clipModelPath = await getClipTextModelPath("onnx"); - onnxTextSession = await createOnnxSession(clipModelPath); - } - return onnxTextSession; -} - -let tokenizer: Tokenizer = null; -function getTokenizer() { - if (!tokenizer) { - tokenizer = new Tokenizer(); - } - return tokenizer; -} - -export const computeImageEmbedding = async ( - model: Model, - imageData: Uint8Array, -): Promise => { - if (!isModel(model)) throw new Error(`Invalid CLIP model ${model}`); - - let tempInputFilePath = null; - try { - tempInputFilePath = await generateTempFilePath(""); - const imageStream = new Response(imageData.buffer).body; - await writeStream(tempInputFilePath, imageStream); - const embedding = await computeImageEmbedding_( - model, - tempInputFilePath, - ); - return embedding; - } catch (err) { - if (isExecError(err)) { - const parsedExecError = parseExecError(err); - throw Error(parsedExecError); - } else { - throw err; - } - } finally { - if (tempInputFilePath) { - await deleteTempFile(tempInputFilePath); - } - } -}; - -const isExecError = (err: any) => { - return err.message.includes("Command failed:"); -}; - -const parseExecError = (err: any) => { - const errMessage = err.message; - if (errMessage.includes("Bad CPU type in executable")) { - return CustomErrors.UNSUPPORTED_PLATFORM( - process.platform, - process.arch, - ); - } else { - return errMessage; - } -}; - -async function computeImageEmbedding_( - model: Model, - inputFilePath: string, -): Promise { - if (!existsSync(inputFilePath)) { - throw new Error("Invalid file path"); - } - switch (model) { - case "ggml-clip": - return await computeGGMLImageEmbedding(inputFilePath); - case "onnx-clip": - return await computeONNXImageEmbedding(inputFilePath); - } -} - -const computeGGMLImageEmbedding = async ( - inputFilePath: string, -): Promise => { - const clipModelPath = await getClipImageModelPath("ggml"); - const ggmlclipPath = getGGMLClipPath(); - const cmd = IMAGE_EMBEDDING_EXTRACT_CMD.map((cmdPart) => { - if (cmdPart === GGMLCLIP_PATH_PLACEHOLDER) { - return ggmlclipPath; - } else if (cmdPart === CLIP_MODEL_PATH_PLACEHOLDER) { - return clipModelPath; - } else if (cmdPart === INPUT_PATH_PLACEHOLDER) { - return inputFilePath; - } else { - return cmdPart; - } - }); - - const { stdout } = await execAsync(cmd); - // parse stdout and return embedding - // get the last line of stdout - const lines = stdout.split("\n"); - const lastLine = lines[lines.length - 1]; - const embedding = JSON.parse(lastLine); - const embeddingArray = new Float32Array(embedding); - return embeddingArray; -}; - -const computeONNXImageEmbedding = async ( - inputFilePath: string, -): Promise => { - const imageSession = await getOnnxImageSession(); - const t1 = Date.now(); - const rgbData = await getRGBData(inputFilePath); - const feeds = { - input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]), - }; - const t2 = Date.now(); - const results = await imageSession.run(feeds); - log.info( - `onnx image embedding time: ${Date.now() - t1} ms (prep:${ - t2 - t1 - } ms, extraction: ${Date.now() - t2} ms)`, - ); - const imageEmbedding = results["output"].data; // Float32Array - return normalizeEmbedding(imageEmbedding); -}; - -async function getRGBData(inputFilePath: string) { - const jpegData = await fs.readFile(inputFilePath); - const rawImageData = jpeg.decode(jpegData, { - useTArray: true, - formatAsRGBA: false, - }); - - const nx: number = rawImageData.width; - const ny: number = rawImageData.height; - const inputImage: Uint8Array = rawImageData.data; - - const nx2: number = 224; - const ny2: number = 224; - const totalSize: number = 3 * nx2 * ny2; - - const result: number[] = Array(totalSize).fill(0); - const scale: number = Math.max(nx, ny) / 224; - - const nx3: number = Math.round(nx / scale); - const ny3: number = Math.round(ny / scale); - - const mean: number[] = [0.48145466, 0.4578275, 0.40821073]; - const std: number[] = [0.26862954, 0.26130258, 0.27577711]; - - for (let y = 0; y < ny3; y++) { - for (let x = 0; x < nx3; x++) { - for (let c = 0; c < 3; c++) { - // linear interpolation - const sx: number = (x + 0.5) * scale - 0.5; - const sy: number = (y + 0.5) * scale - 0.5; - - const x0: number = Math.max(0, Math.floor(sx)); - const y0: number = Math.max(0, Math.floor(sy)); - - const x1: number = Math.min(x0 + 1, nx - 1); - const y1: number = Math.min(y0 + 1, ny - 1); - - const dx: number = sx - x0; - const dy: number = sy - y0; - - const j00: number = 3 * (y0 * nx + x0) + c; - const j01: number = 3 * (y0 * nx + x1) + c; - const j10: number = 3 * (y1 * nx + x0) + c; - const j11: number = 3 * (y1 * nx + x1) + c; - - const v00: number = inputImage[j00]; - const v01: number = inputImage[j01]; - const v10: number = inputImage[j10]; - const v11: number = inputImage[j11]; - - const v0: number = v00 * (1 - dx) + v01 * dx; - const v1: number = v10 * (1 - dx) + v11 * dx; - - const v: number = v0 * (1 - dy) + v1 * dy; - - const v2: number = Math.min(Math.max(Math.round(v), 0), 255); - - // createTensorWithDataList is dump compared to reshape and hence has to be given with one channel after another - const i: number = y * nx3 + x + (c % 3) * 224 * 224; - - result[i] = (v2 / 255 - mean[c]) / std[c]; - } - } - } - - return result; -} - -const normalizeEmbedding = (embedding: Float32Array) => { - let normalization = 0; - for (let index = 0; index < embedding.length; index++) { - normalization += embedding[index] * embedding[index]; - } - const sqrtNormalization = Math.sqrt(normalization); - for (let index = 0; index < embedding.length; index++) { - embedding[index] = embedding[index] / sqrtNormalization; - } - return embedding; -}; - -export async function computeTextEmbedding( - model: Model, - text: string, -): Promise { - if (!isModel(model)) throw new Error(`Invalid CLIP model ${model}`); - - try { - const embedding = computeTextEmbedding_(model, text); - return embedding; - } catch (err) { - if (isExecError(err)) { - const parsedExecError = parseExecError(err); - throw Error(parsedExecError); - } else { - throw err; - } - } -} - -async function computeTextEmbedding_( - model: Model, - text: string, -): Promise { - switch (model) { - case "ggml-clip": - return await computeGGMLTextEmbedding(text); - case "onnx-clip": - return await computeONNXTextEmbedding(text); - } -} - -export async function computeGGMLTextEmbedding( - text: string, -): Promise { - const clipModelPath = await getClipTextModelPath("ggml"); - const ggmlclipPath = getGGMLClipPath(); - const cmd = TEXT_EMBEDDING_EXTRACT_CMD.map((cmdPart) => { - if (cmdPart === GGMLCLIP_PATH_PLACEHOLDER) { - return ggmlclipPath; - } else if (cmdPart === CLIP_MODEL_PATH_PLACEHOLDER) { - return clipModelPath; - } else if (cmdPart === INPUT_PATH_PLACEHOLDER) { - return text; - } else { - return cmdPart; - } - }); - - const { stdout } = await execAsync(cmd); - // parse stdout and return embedding - // get the last line of stdout - const lines = stdout.split("\n"); - const lastLine = lines[lines.length - 1]; - const embedding = JSON.parse(lastLine); - const embeddingArray = new Float32Array(embedding); - return embeddingArray; -} - -export async function computeONNXTextEmbedding( - text: string, -): Promise { - const imageSession = await getOnnxTextSession(); - const t1 = Date.now(); - const tokenizer = getTokenizer(); - const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text)); - const feeds = { - input: new ort.Tensor("int32", tokenizedText, [1, 77]), - }; - const t2 = Date.now(); - const results = await imageSession.run(feeds); - log.info( - `onnx text embedding time: ${Date.now() - t1} ms (prep:${ - t2 - t1 - } ms, extraction: ${Date.now() - t2} ms)`, - ); - const textEmbedding = results["output"].data; // Float32Array - return normalizeEmbedding(textEmbedding); -} diff --git a/desktop/src/services/store.ts b/desktop/src/services/store.ts index 20326dee11..a484080f53 100644 --- a/desktop/src/services/store.ts +++ b/desktop/src/services/store.ts @@ -4,23 +4,22 @@ import { safeStorageStore } from "../stores/safeStorage.store"; import { uploadStatusStore } from "../stores/upload.store"; import { watchStore } from "../stores/watch.store"; -export const clearElectronStore = () => { +export const clearStores = () => { uploadStatusStore.clear(); keysStore.clear(); safeStorageStore.clear(); watchStore.clear(); }; -export async function setEncryptionKey(encryptionKey: string) { +export const saveEncryptionKey = async (encryptionKey: string) => { const encryptedKey: Buffer = await safeStorage.encryptString(encryptionKey); const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64"); safeStorageStore.set("encryptionKey", b64EncryptedKey); -} +}; -export async function getEncryptionKey(): Promise { +export const encryptionKey = async (): Promise => { const b64EncryptedKey = safeStorageStore.get("encryptionKey"); - if (b64EncryptedKey) { - const keyBuffer = Buffer.from(b64EncryptedKey, "base64"); - return await safeStorage.decryptString(keyBuffer); - } -} + if (!b64EncryptedKey) return undefined; + const keyBuffer = Buffer.from(b64EncryptedKey, "base64"); + return await safeStorage.decryptString(keyBuffer); +}; diff --git a/desktop/src/services/userPreference.ts b/desktop/src/services/userPreference.ts index 8074ee4def..c20657aa9c 100644 --- a/desktop/src/services/userPreference.ts +++ b/desktop/src/services/userPreference.ts @@ -1,4 +1,4 @@ -import { userPreferencesStore } from "../stores/userPreferences.store"; +import { userPreferencesStore } from "../stores/user-preferences"; export function getHideDockIconPreference() { return userPreferencesStore.get("hideDockIcon"); @@ -7,27 +7,3 @@ export function getHideDockIconPreference() { export function setHideDockIconPreference(shouldHideDockIcon: boolean) { userPreferencesStore.set("hideDockIcon", shouldHideDockIcon); } - -export function getSkipAppVersion() { - return userPreferencesStore.get("skipAppVersion"); -} - -export function setSkipAppVersion(version: string) { - userPreferencesStore.set("skipAppVersion", version); -} - -export function getMuteUpdateNotificationVersion() { - return userPreferencesStore.get("muteUpdateNotificationVersion"); -} - -export function setMuteUpdateNotificationVersion(version: string) { - userPreferencesStore.set("muteUpdateNotificationVersion", version); -} - -export function clearSkipAppVersion() { - userPreferencesStore.delete("skipAppVersion"); -} - -export function clearMuteUpdateNotificationVersion() { - userPreferencesStore.delete("muteUpdateNotificationVersion"); -} diff --git a/desktop/src/stores/userPreferences.store.ts b/desktop/src/stores/user-preferences.ts similarity index 63% rename from desktop/src/stores/userPreferences.store.ts rename to desktop/src/stores/user-preferences.ts index 9545b1261d..396e7a86c7 100644 --- a/desktop/src/stores/userPreferences.store.ts +++ b/desktop/src/stores/user-preferences.ts @@ -1,7 +1,12 @@ import Store, { Schema } from "electron-store"; -import type { UserPreferencesType } from "../types/main"; -const userPreferencesSchema: Schema = { +interface UserPreferencesSchema { + hideDockIcon: boolean; + skipAppVersion?: string; + muteUpdateNotificationVersion?: string; +} + +const userPreferencesSchema: Schema = { hideDockIcon: { type: "boolean", }, diff --git a/desktop/src/types/ipc.ts b/desktop/src/types/ipc.ts index d1e99b24c0..3dba231f20 100644 --- a/desktop/src/types/ipc.ts +++ b/desktop/src/types/ipc.ts @@ -80,7 +80,3 @@ export interface AppUpdateInfo { autoUpdatable: boolean; version: string; } - -export type Model = "ggml-clip" | "onnx-clip"; - -export const isModel = (s: unknown) => s == "ggml-clip" || s == "onnx-clip"; diff --git a/desktop/src/types/main.ts b/desktop/src/types/main.ts index c875db1ab1..546749c54b 100644 --- a/desktop/src/types/main.ts +++ b/desktop/src/types/main.ts @@ -29,9 +29,3 @@ export const FILE_PATH_KEYS: { export interface SafeStorageStoreType { encryptionKey: string; } - -export interface UserPreferencesType { - hideDockIcon: boolean; - skipAppVersion: string; - muteUpdateNotificationVersion: string; -} diff --git a/docs/docs/self-hosting/guides/custom-server/index.md b/docs/docs/self-hosting/guides/custom-server/index.md index 7f7ba50fa6..bf695af308 100644 --- a/docs/docs/self-hosting/guides/custom-server/index.md +++ b/docs/docs/self-hosting/guides/custom-server/index.md @@ -18,11 +18,6 @@ configure the endpoint the app should be connecting to. ![Setting a custom server on the onboarding screen](custom-server.png) -> [!IMPORTANT] -> -> This is only supported by the Ente Auth app currently. We'll add this same -> functionality to the Ente Photos app soon. - ## CLI > [!NOTE] diff --git a/infra/services/nginx/nginx.service b/infra/services/nginx/nginx.service index e14e7840da..4951c5e75e 100644 --- a/infra/services/nginx/nginx.service +++ b/infra/services/nginx/nginx.service @@ -16,5 +16,6 @@ ExecStart=docker run --name nginx \ -v /root/nginx/cert.pem:/etc/ssl/certs/cert.pem:ro \ -v /root/nginx/key.pem:/etc/ssl/private/key.pem:ro \ -v /root/nginx/conf.d:/etc/nginx/conf.d:ro \ + --log-opt max-size=1g \ nginx ExecReload=docker exec nginx nginx -s reload diff --git a/mobile/fastlane/metadata/android/tr/full_description.txt b/mobile/fastlane/metadata/android/tr/full_description.txt new file mode 100644 index 0000000000..2362583f60 --- /dev/null +++ b/mobile/fastlane/metadata/android/tr/full_description.txt @@ -0,0 +1,36 @@ +ente, fotoğraflarınızı ve videolarınızı yedeklemek ve paylaşmak için basit bir uygulamadır. + +Google Fotoğraflar'a gizlilik dostu bir alternatif arıyorsanız doğru yere geldiniz. Ente ile uçtan uca şifrelenmiş olarak (e2ee) saklanırlar. Bu, onları yalnızca sizin görebileceğiniz anlamına gelir. + +Android, iOS, web ve masaüstünde açık kaynaklı uygulamalarımız var ve fotoğraflarınız bunların tümü arasında uçtan uca şifrelenmiş (e2ee) şekilde sorunsuz bir şekilde senkronize edilecek. + +ente ayrıca, ente'de olmasalar bile albümlerinizi sevdiklerinizle paylaşmanızı da kolaylaştırır. Bir hesap veya uygulama olmadan bile albümünüzü görüntüleyebilecekleri ve albüme fotoğraf ekleyerek ortak çalışabilecekleri, herkese açık olarak görüntülenebilen bağlantıları paylaşabilirsiniz. + +Şifrelenmiş verileriniz, Paris'teki bir serpinti sığınağı da dahil olmak üzere birden fazla yerde depolanır. Gelecek nesilleri ciddiye alıyor ve anılarınızın sizden daha uzun yaşamasını sağlamayı kolaylaştırıyoruz. + +Şimdiye kadarki en güvenli fotoğraf uygulamasını yapmak için buradayız, gelin yolculuğumuza katılın! + +✨ÖZELLİKLER +- Orijinal kalitede yedekler, çünkü her piksel önemlidir +- Aile planları, böylece depolama alanını ailenizle paylaşabilirsiniz +- Seyahatten sonra fotoğrafları bir araya toplayabilmeniz için ortak albümler +- Partnerinizin "Kamera" tıklamalarınızın keyfini çıkarmasını istemeniz durumunda paylaşılan klasörler +- Parola ile korunabilen ve süresi dolacak şekilde ayarlanabilen albüm bağlantıları +- Güvenli bir şekilde yedeklenmiş dosyaları kaldırarak alan boşaltma yeteneği +- İnsan desteği, çünkü sen buna değersin +- Açıklamalar, böylece anılarınıza başlık yazabilir ve onları kolayca bulabilirsiniz +- Son rötuşları eklemek için görüntü düzenleyici +- Favori, sakla ve anılarını yeniden yaşa, çünkü onlar değerlidir +- Google, Apple, sabit diskiniz ve daha fazlasından tek tıkla içe aktarma +- Koyu tema, çünkü fotoğraflarınız bu temada güzel görünüyor +- 2FA, 3FA, biyometrik kimlik doğrulama +- ve çok daha fazlası! + +İZİNLER +bir fotoğraf depolama sağlayıcısının amacına hizmet etmek için belirli izinlere yönelik taleplerde bulunulabilir; bu izinler burada incelenebilir: https://github.com/ente-io/ente/blob/f-droid/mobile/android/permissions.md + +FİYATLANDIRMA +Sonsuza kadar ücretsiz planlar sunmuyoruz, çünkü sürdürülebilir kalmamız ve zamanın testine dayanmamız bizim için önemli. Bunun yerine, ailenizle özgürce paylaşabileceğiniz uygun fiyatlı planlar sunuyoruz. Daha fazla bilgiyi ente.io adresinde bulabilirsiniz. + +🙋DESTEK +İnsan desteği sunmaktan gurur duyuyoruz. Ücretli müşterimiz iseniz team@ente.io adresine ulaşabilir ve ekibimizden 24 saat içinde yanıt bekleyebilirsiniz. diff --git a/mobile/fastlane/metadata/android/tr/short_description.txt b/mobile/fastlane/metadata/android/tr/short_description.txt new file mode 100644 index 0000000000..8995ef3660 --- /dev/null +++ b/mobile/fastlane/metadata/android/tr/short_description.txt @@ -0,0 +1 @@ +ente uçtan uca şifrelenmiş bir fotoğraf depolama uygulamasıdır \ No newline at end of file diff --git a/mobile/fastlane/metadata/android/tr/title.txt b/mobile/fastlane/metadata/android/tr/title.txt new file mode 100644 index 0000000000..85355d495d --- /dev/null +++ b/mobile/fastlane/metadata/android/tr/title.txt @@ -0,0 +1 @@ +ente - şifrelenmiş depolama sistemi \ No newline at end of file diff --git a/mobile/fastlane/metadata/ios/ru/name.txt b/mobile/fastlane/metadata/ios/ru/name.txt index 45bf4920f4..44e95b9fc5 100644 --- a/mobile/fastlane/metadata/ios/ru/name.txt +++ b/mobile/fastlane/metadata/ios/ru/name.txt @@ -1 +1 @@ -ente Фото +ente фотографии diff --git a/mobile/fastlane/metadata/ios/ru/subtitle.txt b/mobile/fastlane/metadata/ios/ru/subtitle.txt index a995d09edf..990f6a1af7 100644 --- a/mobile/fastlane/metadata/ios/ru/subtitle.txt +++ b/mobile/fastlane/metadata/ios/ru/subtitle.txt @@ -1 +1 @@ -Система зашифрованного хранения фотографий +Зашифрованное хранилище фотографий diff --git a/mobile/fastlane/metadata/ios/tr/description.txt b/mobile/fastlane/metadata/ios/tr/description.txt new file mode 100644 index 0000000000..9ee96f2770 --- /dev/null +++ b/mobile/fastlane/metadata/ios/tr/description.txt @@ -0,0 +1,33 @@ +Ente, fotoğraflarınızı ve videolarınızı yedekleyip paylaşmanızı sağlayan kullanimi kolay bir uygulamadır. + +Anılarınızı saklamak için gizlilik dostu bir alternatif arıyorsanız, doğru yere geldiniz. Ente ile uçtan uca şifrelenmiş olarak (e2ee) saklanırlar. Bu, onları yalnızca sizin görebileceğiniz anlamına gelir. + +Android, iOS, web ve Masaüstünde uygulamalarımız var ve fotoğraflarınız tüm cihazlarınız arasında uçtan uca şifrelenmiş (e2ee) bir şekilde sorunsuz bir şekilde senkronize edilecek. + +Ente, albümlerinizi sevdiklerinizle paylaşmanızı da kolaylaştırıyor. Bunları uçtan uca şifrelenmiş olarak doğrudan diğer Ente kullanıcılarıyla paylaşabilir veya herkese açık olarak görüntülenebilir bağlantılarla paylaşabilirsiniz. + +Şifrelenmiş verileriniz, Paris'teki bir serpinti sığınağı da dahil olmak üzere birden fazla yerde depolanır. Gelecek nesilleri ciddiye alıyor ve anılarınızın sizden daha uzun yaşamasını sağlamayı kolaylaştırıyoruz. + +Şimdiye kadarki en güvenli fotoğraf uygulamasını yapmak için buradayız, gelin yolculuğumuza katılın! + +✨ÖZELLİKLER +- Orijinal kalitede yedekler, çünkü her piksel önemlidir +- Aile planları, böylece depolama alanını ailenizle paylaşabilirsiniz +- Partnerinizin "Kamera" tıklamalarınızın keyfini çıkarmasını istemeniz durumunda paylaşılan klasörler +- Parola ile korunabilen ve süresi dolacak şekilde ayarlanabilen albüm bağlantıları +- Güvenli bir şekilde yedeklenmiş dosyaları kaldırarak alan boşaltma yeteneği +- Son rötuşları eklemek için görüntü düzenleyici +- Favori, sakla ve anılarını yeniden yaşa, çünkü onlar değerlidir +- Tüm büyük depolama sağlayıcılarından tek tıklamayla içe aktarma +- Koyu tema, çünkü fotoğraflarınız bu temada güzel görünüyor +- 2FA, 3FA, biyometrik kimlik doğrulama +- ve çok daha fazlası! + +FİYATLANDIRMA +Sonsuza kadar ücretsiz planlar sunmuyoruz, çünkü sürdürülebilir kalmamız ve zamanın testine dayanmamız bizim için önemli. Bunun yerine, ailenizle özgürce paylaşabileceğiniz uygun fiyatlı planlar sunuyoruz. Daha fazla bilgiyi ente.io adresinde bulabilirsiniz. + +🙋DESTEK +İnsan desteği sunmaktan gurur duyuyoruz. Ücretli müşterimiz iseniz team@ente.io adresine ulaşabilir ve ekibimizden 24 saat içinde yanıt bekleyebilirsiniz. + +ŞARTLAR +https://ente.io/terms diff --git a/mobile/fastlane/metadata/ios/tr/keywords.txt b/mobile/fastlane/metadata/ios/tr/keywords.txt new file mode 100644 index 0000000000..4e9f74bd7d --- /dev/null +++ b/mobile/fastlane/metadata/ios/tr/keywords.txt @@ -0,0 +1 @@ +fotoğraflar,fotoğrafçılık,aile,gizlilik,bulut,yedekleme,videolar,fotoğraf,şifreleme,depolama,albüm,alternatif diff --git a/mobile/fastlane/metadata/ios/tr/name.txt b/mobile/fastlane/metadata/ios/tr/name.txt new file mode 100644 index 0000000000..04e5a9c011 --- /dev/null +++ b/mobile/fastlane/metadata/ios/tr/name.txt @@ -0,0 +1 @@ +ente fotoğraf uygulaması diff --git a/mobile/fastlane/metadata/ios/tr/subtitle.txt b/mobile/fastlane/metadata/ios/tr/subtitle.txt new file mode 100644 index 0000000000..cbc438b5f0 --- /dev/null +++ b/mobile/fastlane/metadata/ios/tr/subtitle.txt @@ -0,0 +1 @@ +Şifrelenmiş depolama sistemi diff --git a/mobile/fastlane/metadata/playstore/tr/full_description.txt b/mobile/fastlane/metadata/playstore/tr/full_description.txt new file mode 100644 index 0000000000..b13229830d --- /dev/null +++ b/mobile/fastlane/metadata/playstore/tr/full_description.txt @@ -0,0 +1,30 @@ +Ente, fotoğraflarınızı ve videolarınızı yedekleyip paylaşmanızı sağlayan kullanimi kolay bir uygulamadır. + +Anılarınızı saklamak için gizlilik dostu bir alternatif arıyorsanız, doğru yere geldiniz. Ente ile uçtan uca şifrelenmiş olarak (e2ee) saklanırlar. Bu, onları yalnızca sizin görüntüleyebileceğiniz anlamına gelir. + +Android, iOS, web ve Masaüstünde uygulamalarımız var ve fotoğraflarınız tüm cihazlarınız arasında uçtan uca şifrelenmiş (e2ee) bir şekilde sorunsuz bir şekilde senkronize edilecek. + +Ente, albümlerinizi sevdiklerinizle paylaşmanızı da kolaylaştırıyor. Bunları uçtan uca şifrelenmiş olarak doğrudan diğer Ente kullanıcılarıyla paylaşabilir veya herkese açık olarak görüntülenebilir bağlantılarla paylaşabilirsiniz. + +Şifrelenmiş verileriniz, Paris'teki bir serpinti sığınağı da dahil olmak üzere birden fazla yerde depolanır. Gelecek nesilleri ciddiye alıyor ve anılarınızın sizden daha uzun yaşamasını sağlamayı kolaylaştırıyoruz. + +Şimdiye kadarki en güvenli fotoğraf uygulamasını yapmak için buradayız, gelin yolculuğumuza katılın! + +✨ÖZELLİKLER +- Orijinal kalitede yedekler, çünkü her piksel önemlidir +- Aile planları, böylece depolama alanını ailenizle paylaşabilirsiniz +- Partnerinizin "Kamera" tıklamalarınızın keyfini çıkarmasını istemeniz durumunda paylaşılan klasörler +- Parola ile korunabilen ve süresi dolacak şekilde ayarlanabilen albüm bağlantıları +- Güvenli bir şekilde yedeklenmiş dosyaları kaldırarak alan boşaltma yeteneği +- Son rötuşları eklemek için görüntü düzenleyici +- Favori, sakla ve anılarını yeniden yaşa, çünkü onlar değerlidir +- Google, Apple, sabit diskiniz ve daha fazlasından tek tıkla içe aktarma +- Koyu tema, çünkü fotoğraflarınız bu temada güzel görünüyor +- 2FA, 3FA, biyometrik kimlik doğrulama +- ve çok daha fazlası! + +💲 FİYATLANDIRMA +Sonsuza kadar ücretsiz planlar sunmuyoruz, çünkü sürdürülebilir kalmamız ve zamanın testine dayanmamız bizim için önemli. Bunun yerine, ailenizle özgürce paylaşabileceğiniz uygun fiyatlı planlar sunuyoruz. Daha fazla bilgiyi ente.io adresinde bulabilirsiniz. + +🙋DESTEK +İnsan desteği sunmaktan gurur duyuyoruz. Ücretli müşterimiz iseniz team@ente.io adresine ulaşabilir ve ekibimizden 24 saat içinde yanıt bekleyebilirsiniz. \ No newline at end of file diff --git a/mobile/fastlane/metadata/playstore/tr/short_description.txt b/mobile/fastlane/metadata/playstore/tr/short_description.txt new file mode 100644 index 0000000000..009cb00bfa --- /dev/null +++ b/mobile/fastlane/metadata/playstore/tr/short_description.txt @@ -0,0 +1 @@ +Şifreli fotoğraf depolama - fotoğraflarınızı ve videolarınızı yedekleyin, düzenleyin ve paylaşın \ No newline at end of file diff --git a/mobile/lib/l10n/intl_pt.arb b/mobile/lib/l10n/intl_pt.arb index ee3a2200cb..37b1041a9a 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -225,7 +225,7 @@ }, "description": "Number of participants in an album, including the album owner." }, - "collabLinkSectionDescription": "Crie um link para permitir pessoas adicionar e ver fotos no seu álbum compartilhado sem a necessidade do aplicativo ou uma conta Ente. Ótimo para colecionar fotos de eventos.", + "collabLinkSectionDescription": "Crie um link para permitir que as pessoas adicionem e vejam fotos no seu álbum compartilhado sem a necessidade do aplicativo ou uma conta Ente. Ótimo para colecionar fotos de eventos.", "collectPhotos": "Colete fotos", "collaborativeLink": "Link Colaborativo", "shareWithNonenteUsers": "Compartilhar com usuários não-Ente", @@ -259,12 +259,12 @@ }, "verificationId": "ID de Verificação", "verifyEmailID": "Verificar {email}", - "emailNoEnteAccount": "{email} Não possui uma conta Ente.\n\nEnvie um convite para compartilhar fotos.", + "emailNoEnteAccount": "{email} não possui uma conta Ente.\n\nEnvie um convite para compartilhar fotos.", "shareMyVerificationID": "Aqui está meu ID de verificação para o Ente.io: {verificationID}", "shareTextConfirmOthersVerificationID": "Ei, você pode confirmar que este é seu ID de verificação do Ente.io? {verificationID}", "somethingWentWrong": "Algo deu errado", "sendInvite": "Enviar convite", - "shareTextRecommendUsingEnte": "Baixe o Ente para podermos compartilhar facilmente fotos e vídeos de alta qualidade\n\nhttps://ente.io", + "shareTextRecommendUsingEnte": "Baixe o Ente para que possamos compartilhar facilmente fotos e vídeos de qualidade original\n\nhttps://ente.io", "done": "Concluído", "applyCodeTitle": "Aplicar código", "enterCodeDescription": "Digite o código fornecido pelo seu amigo para reivindicar o armazenamento gratuito para vocês dois", @@ -350,8 +350,8 @@ "videoSmallCase": "Video", "photoSmallCase": "Foto", "singleFileDeleteHighlight": "Ele será excluído de todos os álbuns.", - "singleFileInBothLocalAndRemote": "Este {fileType} está em ente e no seu dispositivo.", - "singleFileInRemoteOnly": "Este {fileType} será excluído do ente.", + "singleFileInBothLocalAndRemote": "Este {fileType} está tanto no Ente quanto no seu dispositivo.", + "singleFileInRemoteOnly": "Este {fileType} será excluído do Ente.", "singleFileDeleteFromDevice": "Este {fileType} será excluído do seu dispositivo.", "deleteFromEnte": "Excluir do ente", "yesDelete": "Sim, excluir", @@ -445,7 +445,7 @@ "backupOverMobileData": "Backup de dados móveis", "backupVideos": "Backup de videos", "disableAutoLock": "Desativar bloqueio automático", - "deviceLockExplanation": "Desative o bloqueio de tela do dispositivo quando o ente estiver em primeiro plano e houver um backup em andamento. Isso normalmente não é necessário, mas pode ajudar grandes uploads e importações iniciais de grandes bibliotecas a serem concluídos mais rapidamente.", + "deviceLockExplanation": "Desative o bloqueio de tela do dispositivo quando o Ente estiver em primeiro plano e houver um backup em andamento. Isso normalmente não é necessário, mas pode ajudar nos envios grandes e importações iniciais de grandes bibliotecas a serem concluídos mais rapidamente.", "about": "Sobre", "weAreOpenSource": "Somos de código aberto!", "privacy": "Privacidade", @@ -464,8 +464,8 @@ "logout": "Encerrar sessão", "authToInitiateAccountDeletion": "Por favor, autentique-se para iniciar a exclusão de conta", "areYouSureYouWantToLogout": "Você tem certeza que deseja encerrar a sessão?", - "yesLogout": "Sim, terminar sessão", - "aNewVersionOfEnteIsAvailable": "Uma nova versão do ente está disponível.", + "yesLogout": "Sim, encerrar sessão", + "aNewVersionOfEnteIsAvailable": "Uma nova versão do Ente está disponível.", "update": "Atualização", "installManually": "Instalar manualmente", "criticalUpdateAvailable": "Atualização crítica disponível", @@ -515,7 +515,7 @@ } }, "familyPlans": "Plano familiar", - "referrals": "Indicações", + "referrals": "Referências", "notifications": "Notificações", "sharedPhotoNotifications": "Novas fotos compartilhadas", "sharedPhotoNotificationsExplanation": "Receber notificações quando alguém adicionar uma foto em um álbum compartilhado que você faz parte", @@ -554,11 +554,11 @@ "systemTheme": "Sistema", "freeTrial": "Teste gratuito", "selectYourPlan": "Selecione seu plano", - "enteSubscriptionPitch": "O ente preserva suas memórias, então eles estão sempre disponíveis para você, mesmo se você perder o seu dispositivo.", + "enteSubscriptionPitch": "O Ente preserva suas memórias, então eles estão sempre disponíveis para você, mesmo se você perder o seu dispositivo.", "enteSubscriptionShareWithFamily": "Sua família também pode ser adicionada ao seu plano.", "currentUsageIs": "O uso atual é ", "@currentUsageIs": { - "description": "This text is followed by storage usaged", + "description": "This text is followed by storage usage", "examples": { "0": "Current usage is 1.2 GB" }, @@ -620,7 +620,7 @@ "appleId": "ID da Apple", "playstoreSubscription": "Assinatura da PlayStore", "appstoreSubscription": "Assinatura da AppStore", - "subAlreadyLinkedErrMessage": "Seu {id} já está vinculado a outra conta ente.\nSe você gostaria de usar seu {id} com esta conta, por favor contate nosso suporte''", + "subAlreadyLinkedErrMessage": "Seu {id} já está vinculado a outra conta Ente.\nSe você gostaria de usar seu {id} com esta conta, por favor contate nosso suporte''", "visitWebToManage": "Por favor visite web.ente.io para gerenciar sua assinatura", "couldNotUpdateSubscription": "Não foi possível atualizar a assinatura", "pleaseContactSupportAndWeWillBeHappyToHelp": "Por favor, entre em contato com support@ente.io e nós ficaremos felizes em ajudar!", @@ -665,9 +665,9 @@ "everywhere": "em todos os lugares", "androidIosWebDesktop": "Android, iOS, Web, Desktop", "mobileWebDesktop": "Mobile, Web, Desktop", - "newToEnte": "Novo no ente", + "newToEnte": "Novo no Ente", "pleaseLoginAgain": "Por favor, faça login novamente", - "devAccountChanged": "A conta de desenvolvedor que usamos para publicar o ente na App Store foi alterada. Por esse motivo, você precisará fazer login novamente.\n\nPedimos desculpas pelo inconveniente, mas isso era inevitável.", + "devAccountChanged": "A conta de desenvolvedor que usamos para publicar o Ente na App Store foi alterada. Por esse motivo, você precisará fazer entrar novamente.\n\nPedimos desculpas pelo inconveniente, mas isso era inevitável.", "yourSubscriptionHasExpired": "A sua assinatura expirou", "storageLimitExceeded": "Limite de armazenamento excedido", "upgrade": "Aprimorar", @@ -678,12 +678,12 @@ }, "backupFailed": "Erro ao efetuar o backup", "couldNotBackUpTryLater": "Não foi possível fazer o backup de seus dados.\nTentaremos novamente mais tarde.", - "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "ente pode criptografar e preservar arquivos somente se você conceder acesso a eles", + "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "Ente pode criptografar e preservar arquivos apenas se você conceder acesso a eles", "pleaseGrantPermissions": "Por favor, conceda as permissões", "grantPermission": "Garantir permissão", "privateSharing": "Compartilhamento privado", "shareOnlyWithThePeopleYouWant": "Compartilhar apenas com as pessoas que você quiser", - "usePublicLinksForPeopleNotOnEnte": "Usar links públicos para pessoas que não estão no ente", + "usePublicLinksForPeopleNotOnEnte": "Usar links públicos para pessoas que não estão no Ente", "allowPeopleToAddPhotos": "Permitir que pessoas adicionem fotos", "shareAnAlbumNow": "Compartilhar um álbum agora", "collectEventPhotos": "Coletar fotos do evento", @@ -695,7 +695,7 @@ }, "onDevice": "No dispositivo", "@onEnte": { - "description": "The text displayed above albums backed up to ente", + "description": "The text displayed above albums backed up to Ente", "type": "text" }, "onEnte": "Em ente", @@ -741,9 +741,9 @@ "saveCollage": "Salvar colagem", "collageSaved": "Colagem salva na galeria", "collageLayout": "Layout", - "addToEnte": "Adicionar ao ente", + "addToEnte": "Adicionar ao Ente", "addToAlbum": "Adicionar ao álbum", - "delete": "Apagar", + "delete": "Excluir", "hide": "Ocultar", "share": "Compartilhar", "unhideToAlbum": "Reexibir para o álbum", @@ -806,10 +806,10 @@ "photosAddedByYouWillBeRemovedFromTheAlbum": "As fotos adicionadas por você serão removidas do álbum", "youveNoFilesInThisAlbumThatCanBeDeleted": "Você não tem arquivos neste álbum que possam ser excluídos", "youDontHaveAnyArchivedItems": "Você não tem nenhum item arquivado.", - "ignoredFolderUploadReason": "Alguns arquivos neste álbum são ignorados do upload porque eles tinham sido anteriormente excluídos do ente.", + "ignoredFolderUploadReason": "Alguns arquivos neste álbum são ignorados do envio porque eles tinham sido anteriormente excluídos do Ente.", "resetIgnoredFiles": "Redefinir arquivos ignorados", - "deviceFilesAutoUploading": "Arquivos adicionados a este álbum do dispositivo serão automaticamente enviados para o ente.", - "turnOnBackupForAutoUpload": "Ative o backup para enviar automaticamente arquivos adicionados a esta pasta do dispositivo para o ente.", + "deviceFilesAutoUploading": "Arquivos adicionados a este álbum do dispositivo serão automaticamente enviados para o Ente.", + "turnOnBackupForAutoUpload": "Ative o backup para enviar automaticamente arquivos adicionados a esta pasta do dispositivo para o Ente.", "noHiddenPhotosOrVideos": "Nenhuma foto ou vídeos ocultos", "toHideAPhotoOrVideo": "Para ocultar uma foto ou vídeo", "openTheItem": "• Abra o item", @@ -886,7 +886,7 @@ "@freeUpSpaceSaving": { "description": "Text to tell user how much space they can free up by deleting items from the device" }, - "freeUpAccessPostDelete": "Você ainda pode acessar {count, plural, one {ele} other {eles}} no ente contanto que você tenha uma assinatura ativa", + "freeUpAccessPostDelete": "Você ainda pode acessar {count, plural, one {ele} other {eles}} no Ente contanto que você tenha uma assinatura ativa", "@freeUpAccessPostDelete": { "placeholders": { "count": { @@ -937,7 +937,7 @@ "renameFile": "Renomear arquivo", "enterFileName": "Digite o nome do arquivo", "filesDeleted": "Arquivos excluídos", - "selectedFilesAreNotOnEnte": "Os arquivos selecionados não estão no ente", + "selectedFilesAreNotOnEnte": "Os arquivos selecionados não estão no Ente", "thisActionCannotBeUndone": "Esta ação não pode ser desfeita", "emptyTrash": "Esvaziar a lixeira?", "permDeleteWarning": "Todos os itens na lixeira serão excluídos permanentemente\n\nEsta ação não pode ser desfeita", @@ -946,7 +946,7 @@ "permanentlyDeleteFromDevice": "Excluir permanentemente do dispositivo?", "someOfTheFilesYouAreTryingToDeleteAre": "Alguns dos arquivos que você está tentando excluir só estão disponíveis no seu dispositivo e não podem ser recuperados se forem excluídos", "theyWillBeDeletedFromAllAlbums": "Ele será excluído de todos os álbuns.", - "someItemsAreInBothEnteAndYourDevice": "Alguns itens estão tanto no ente quanto no seu dispositivo.", + "someItemsAreInBothEnteAndYourDevice": "Alguns itens estão tanto no Ente quanto no seu dispositivo.", "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Os itens selecionados serão excluídos de todos os álbuns e movidos para o lixo.", "theseItemsWillBeDeletedFromYourDevice": "Estes itens serão excluídos do seu dispositivo.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Parece que algo deu errado. Por favor, tente novamente mais tarde. Se o erro persistir, entre em contato com nossa equipe de suporte.", @@ -1052,7 +1052,7 @@ }, "setRadius": "Definir raio", "familyPlanPortalTitle": "Família", - "familyPlanOverview": "Adicione 5 membros da família ao seu plano existente sem pagar a mais.\n\nCada membro recebe seu próprio espaço privado, e nenhum membro pode ver os arquivos uns dos outros a menos que sejam compartilhados.\n\nPlanos de família estão disponíveis para os clientes que têm uma assinatura de ente paga.\n\nassine agora para começar!", + "familyPlanOverview": "Adicione 5 membros da família ao seu plano existente sem pagar a mais.\n\nCada membro recebe seu próprio espaço privado, e nenhum membro pode ver os arquivos uns dos outros a menos que sejam compartilhados.\n\nPlanos de família estão disponíveis para os clientes que têm uma assinatura do Ente paga.\n\nAssine agora para começar!", "androidBiometricHint": "Verificar identidade", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -1113,7 +1113,7 @@ }, "maps": "Mapas", "enableMaps": "Habilitar mapa", - "enableMapsDesc": "Isto mostrará suas fotos em um mapa do mundo.\n\nEste mapa é hospedado pelo Open Street Map, e os exatos locais de suas fotos nunca são compartilhados.\n\nVocê pode desativar esse recurso a qualquer momento nas Configurações.", + "enableMapsDesc": "Isto mostrará suas fotos em um mapa do mundo.\n\nEste mapa é hospedado pelo OpenStreetMap, e os exatos locais de suas fotos nunca são compartilhados.\n\nVocê pode desativar esse recurso a qualquer momento nas Configurações.", "quickLinks": "Links rápidos", "selectItemsToAdd": "Selecionar itens para adicionar", "addSelected": "Adicionar selecionado", @@ -1130,7 +1130,7 @@ "noAlbumsSharedByYouYet": "Nenhum álbum compartilhado por você ainda", "sharedWithYou": "Compartilhado com você", "sharedByYou": "Compartilhado por você", - "inviteYourFriendsToEnte": "Convide seus amigos ao ente", + "inviteYourFriendsToEnte": "Convide seus amigos ao Ente", "failedToDownloadVideo": "Falha ao baixar vídeo", "hiding": "Ocultando...", "unhiding": "Desocultando...", @@ -1140,7 +1140,7 @@ "addToHiddenAlbum": "Adicionar a álbum oculto", "moveToHiddenAlbum": "Mover para álbum oculto", "fileTypes": "Tipos de arquivo", - "deleteConfirmDialogBody": "Esta conta está vinculada a outros aplicativos ente, se você usar algum. Seus dados enviados, em todos os aplicativos ente, serão agendados para exclusão, e sua conta será excluída permanentemente.", + "deleteConfirmDialogBody": "Esta conta está vinculada a outros aplicativos Ente, se você usar algum. Seus dados enviados, em todos os aplicativos Ente, serão agendados para exclusão, e sua conta será excluída permanentemente.", "hearUsWhereTitle": "Como você ouviu sobre o Ente? (opcional)", "hearUsExplanation": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!", "viewAddOnButton": "Ver complementos", @@ -1178,9 +1178,9 @@ "contacts": "Contatos", "noInternetConnection": "Sem conexão à internet", "pleaseCheckYourInternetConnectionAndTryAgain": "Verifique sua conexão com a internet e tente novamente.", - "signOutFromOtherDevices": "Terminar sessão em outros dispositivos", + "signOutFromOtherDevices": "Encerrar sessão em outros dispositivos", "signOutOtherBody": "Se você acha que alguém pode saber sua senha, você pode forçar todos os outros dispositivos que estão com sua conta a desconectar.", - "signOutOtherDevices": "Terminar sessão em outros dispositivos", + "signOutOtherDevices": "Encerrar sessão em outros dispositivos", "doNotSignOut": "Não encerrar sessão", "editLocation": "Editar local", "selectALocation": "Selecionar um local", @@ -1204,5 +1204,12 @@ "addViewers": "{count, plural, zero {Adicionar visualizador} one {Adicionar visualizador} other {Adicionar Visualizadores}}", "addCollaborators": "{count, plural, zero {Adicionar colaborador} one {Adicionar coloborador} other {Adicionar colaboradores}}", "longPressAnEmailToVerifyEndToEndEncryption": "Pressione e segure um e-mail para verificar a criptografia de ponta a ponta.", - "createCollaborativeLink": "Create collaborative link" + "developerSettingsWarning": "Tem certeza de que deseja modificar as configurações de Desenvolvedor?", + "developerSettings": "Configurações de desenvolvedor", + "serverEndpoint": "Servidor endpoint", + "invalidEndpoint": "Endpoint inválido", + "invalidEndpointMessage": "Desculpe, o endpoint que você inseriu é inválido. Por favor, insira um endpoint válido e tente novamente.", + "endpointUpdatedMessage": "Endpoint atualizado com sucesso", + "customEndpoint": "Conectado a {endpoint}", + "createCollaborativeLink": "Criar link colaborativo" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_zh.arb b/mobile/lib/l10n/intl_zh.arb index c06752ac78..8eb97df784 100644 --- a/mobile/lib/l10n/intl_zh.arb +++ b/mobile/lib/l10n/intl_zh.arb @@ -23,7 +23,7 @@ "sendEmail": "发送电子邮件", "deleteRequestSLAText": "您的请求将在 72 小时内处理。", "deleteEmailRequest": "请从您注册的电子邮件地址发送电子邮件到 account-delettion@ente.io。", - "entePhotosPerm": "ente 需要许可才能保存您的照片", + "entePhotosPerm": "Ente 需要许可才能保存您的照片", "ok": "OK", "createAccount": "创建账户", "createNewAccount": "创建新账号", @@ -127,7 +127,7 @@ } } }, - "twofactorSetup": "双因素认证设置", + "twofactorSetup": "双重认证设置", "enterCode": "输入代码", "scanCode": "扫描二维码/条码", "codeCopiedToClipboard": "代码已复制到剪贴板", @@ -138,9 +138,9 @@ "confirm": "确认", "setupComplete": "设置完成", "saveYourRecoveryKeyIfYouHaventAlready": "若您尚未保存,请妥善保存此恢复密钥", - "thisCanBeUsedToRecoverYourAccountIfYou": "如果您丢失了双因素验证方式,这可以用来恢复您的账户", - "twofactorAuthenticationPageTitle": "双因素认证", - "lostDevice": "丢失了设备吗?", + "thisCanBeUsedToRecoverYourAccountIfYou": "如果您丢失了双重认证方式,这可以用来恢复您的账户", + "twofactorAuthenticationPageTitle": "双重认证", + "lostDevice": "设备丢失?", "verifyingRecoveryKey": "正在验证恢复密钥...", "recoveryKeyVerified": "恢复密钥已验证", "recoveryKeySuccessBody": "太棒了! 您的恢复密钥是有效的。 感谢您的验证。\n\n请记住要安全备份您的恢复密钥。", @@ -225,17 +225,17 @@ }, "description": "Number of participants in an album, including the album owner." }, - "collabLinkSectionDescription": "创建一个链接以允许其他人在您的共享相册中添加和查看照片,而无需应用程序或ente账户。 非常适合收集活动照片。", + "collabLinkSectionDescription": "创建一个链接来让他人无需 Ente 应用程序或账户即可在您的共享相册中添加和查看照片。非常适合收集活动照片。", "collectPhotos": "收集照片", "collaborativeLink": "协作链接", - "shareWithNonenteUsers": "与非ente 用户分享", + "shareWithNonenteUsers": "与非 Ente 用户共享", "createPublicLink": "创建公开链接", "sendLink": "发送链接", "copyLink": "复制链接", "linkHasExpired": "链接已过期", "publicLinkEnabled": "公开链接已启用", "shareALink": "分享链接", - "sharedAlbumSectionDescription": "与其他ente用户创建共享和协作相册,包括免费计划的用户。", + "sharedAlbumSectionDescription": "与其他 Ente 用户(包括免费计划用户)创建共享和协作相册。", "shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {与特定人员共享} =1 {与 1 人共享} other {与 {numberOfPeople} 人共享}}", "@shareWithPeopleSectionTitle": { "placeholders": { @@ -259,12 +259,12 @@ }, "verificationId": "验证 ID", "verifyEmailID": "验证 {email}", - "emailNoEnteAccount": "{email} 没有 ente 账户。\n\n向他们发送分享照片的邀请。", + "emailNoEnteAccount": "{email} 没有 Ente 帐户。\n\n向他们发出共享照片的邀请。", "shareMyVerificationID": "这是我的ente.io 的验证 ID: {verificationID}。", "shareTextConfirmOthersVerificationID": "嘿,你能确认这是你的 ente.io 验证 ID吗:{verificationID}", "somethingWentWrong": "出了些问题", "sendInvite": "发送邀请", - "shareTextRecommendUsingEnte": "下载 ente,以便我们轻松分享原始质量的照片和视频\n\nhttps://ente.io", + "shareTextRecommendUsingEnte": "下载 Ente,让我们轻松共享高质量的原始照片和视频", "done": "已完成", "applyCodeTitle": "应用代码", "enterCodeDescription": "输入您的朋友提供的代码来为您申请免费存储", @@ -281,7 +281,7 @@ "claimMore": "领取更多!", "theyAlsoGetXGb": "他们也会获得 {storageAmountInGB} GB", "freeStorageOnReferralSuccess": "每当有人使用您的代码注册付费计划时您将获得{storageAmountInGB} GB", - "shareTextReferralCode": "ente推荐码: {referralCode} \n\n注册付费计划后在设置 → 常规 → 推荐中应用它以免费获得 {referralStorageInGB} GB空间\n\nhttps://ente.io", + "shareTextReferralCode": "Ente 推荐代码:{referralCode}\n\n在 \"设置\"→\"通用\"→\"推荐 \"中应用它,即可在注册付费计划后免费获得 {referralStorageInGB} GB 存储空间\n\nhttps://ente.io", "claimFreeStorage": "领取免费存储", "inviteYourFriends": "邀请您的朋友", "failedToFetchReferralDetails": "无法获取引荐详细信息。 请稍后再试。", @@ -334,9 +334,9 @@ "removeParticipantBody": "{userEmail} 将从这个共享相册中删除\n\nTA们添加的任何照片也将从相册中删除", "keepPhotos": "保留照片", "deletePhotos": "删除照片", - "inviteToEnte": "邀请到 ente", + "inviteToEnte": "邀请到 Ente", "removePublicLink": "删除公开链接", - "disableLinkMessage": "这将删除用于访问\"{albumName}\"的公共链接。", + "disableLinkMessage": "这将删除用于访问\"{albumName}\"的公开链接。", "sharing": "正在分享...", "youCannotShareWithYourself": "莫开玩笑,您不能与自己分享", "archive": "存档", @@ -350,10 +350,10 @@ "videoSmallCase": "视频", "photoSmallCase": "照片", "singleFileDeleteHighlight": "它将从所有相册中删除。", - "singleFileInBothLocalAndRemote": "此 {fileType} 同时在ente和您的设备中。", - "singleFileInRemoteOnly": "此 {fileType} 将从ente中删除。", + "singleFileInBothLocalAndRemote": "{fileType} 已同时存在于 Ente 和您的设备中。", + "singleFileInRemoteOnly": "{fileType} 将从 Ente 中删除。", "singleFileDeleteFromDevice": "此 {fileType} 将从您的设备中删除。", - "deleteFromEnte": "从ente 中删除", + "deleteFromEnte": "从 Ente 中删除", "yesDelete": "是的, 删除", "movedToTrash": "已移至回收站", "deleteFromDevice": "从设备中删除", @@ -445,7 +445,7 @@ "backupOverMobileData": "通过移动数据备份", "backupVideos": "备份视频", "disableAutoLock": "禁用自动锁定", - "deviceLockExplanation": "当 ente 在前台并且正在进行备份时禁用设备屏幕锁定。 这通常不需要,但可以帮助大型库的大上传和初始导入更快地完成。", + "deviceLockExplanation": "当 Ente 置于前台且正在进行备份时将禁用设备屏幕锁定。这通常是不需要的,但可能有助于更快地完成大型上传和大型库的初始导入。", "about": "关于", "weAreOpenSource": "我们是开源的 !", "privacy": "隐私", @@ -465,7 +465,7 @@ "authToInitiateAccountDeletion": "请进行身份验证以启动账户删除", "areYouSureYouWantToLogout": "您确定要退出登录吗?", "yesLogout": "是的,退出登陆", - "aNewVersionOfEnteIsAvailable": "有新版本的 ente 可供使用。", + "aNewVersionOfEnteIsAvailable": "有新版本的 Ente 可供使用。", "update": "更新", "installManually": "手动安装", "criticalUpdateAvailable": "可用的关键更新", @@ -515,7 +515,7 @@ } }, "familyPlans": "家庭计划", - "referrals": "推荐人", + "referrals": "推荐", "notifications": "通知", "sharedPhotoNotifications": "新共享的照片", "sharedPhotoNotificationsExplanation": "当有人将照片添加到您所属的共享相册时收到通知", @@ -523,15 +523,15 @@ "general": "通用", "security": "安全", "authToViewYourRecoveryKey": "请验证以查看您的恢复密钥", - "twofactor": "两因素认证", - "authToConfigureTwofactorAuthentication": "请进行身份验证以配置双重身份验证", + "twofactor": "双重认证", + "authToConfigureTwofactorAuthentication": "请进行身份验证以配置双重身份认证", "lockscreen": "锁屏", "authToChangeLockscreenSetting": "请验证以更改锁屏设置", "lockScreenEnablePreSteps": "要启用锁屏,请在系统设置中设置设备密码或屏幕锁定。", "viewActiveSessions": "查看活动会话", "authToViewYourActiveSessions": "请验证以查看您的活动会话", - "disableTwofactor": "禁用双因素认证", - "confirm2FADisable": "您确定要禁用双因素认证吗?", + "disableTwofactor": "禁用双重认证", + "confirm2FADisable": "您确定要禁用双重认证吗?", "no": "否", "yes": "是", "social": "社交", @@ -554,11 +554,11 @@ "systemTheme": "适应系统", "freeTrial": "免费试用", "selectYourPlan": "选择您的计划", - "enteSubscriptionPitch": "ente 会保留您的回忆,因此即使您丢失了设备,它们也始终可供您使用。", + "enteSubscriptionPitch": "Ente 会保留您的回忆,因此即使您丢失了设备,也能随时找到它们。", "enteSubscriptionShareWithFamily": "您的家人也可以添加到您的计划中。", "currentUsageIs": "当前用量 ", "@currentUsageIs": { - "description": "This text is followed by storage usaged", + "description": "This text is followed by storage usage", "examples": { "0": "Current usage is 1.2 GB" }, @@ -620,7 +620,7 @@ "appleId": "Apple ID", "playstoreSubscription": "PlayStore 订阅", "appstoreSubscription": "AppStore 订阅", - "subAlreadyLinkedErrMessage": "您的 {id} 已经链接到另一个ente账户。\n如果您想要通过此账户使用您的 {id} ,请联系我们的客服''", + "subAlreadyLinkedErrMessage": "您的 {id} 已链接到另一个 Ente 账户。\n如果您想在此账户中使用您的 {id} ,请联系我们的支持人员", "visitWebToManage": "请访问 web.ente.io 来管理您的订阅", "couldNotUpdateSubscription": "无法升级订阅", "pleaseContactSupportAndWeWillBeHappyToHelp": "请用英语联系 support@ente.io ,我们将乐意提供帮助!", @@ -665,9 +665,9 @@ "everywhere": "随时随地", "androidIosWebDesktop": "安卓, iOS, 网页端, 桌面端", "mobileWebDesktop": "移动端, 网页端, 桌面端", - "newToEnte": "刚来到ente", + "newToEnte": "初来 Ente", "pleaseLoginAgain": "请重新登录", - "devAccountChanged": "我们用于在 App Store 上发布 ente 的开发者账户已更改。 因此,您将需要重新登录。\n\n对于给您带来的不便,我们深表歉意,但这是不可避免的。", + "devAccountChanged": "我们用于在 App Store 上发布 Ente 的开发者账户已更改。因此,您需要重新登录。\n\n对于给您带来的不便,我们深表歉意,但这是不可避免的。", "yourSubscriptionHasExpired": "您的订阅已过期", "storageLimitExceeded": "已超出存储限制", "upgrade": "升级", @@ -678,12 +678,12 @@ }, "backupFailed": "备份失败", "couldNotBackUpTryLater": "我们无法备份您的数据。\n我们将稍后再试。", - "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "只有您授予访问权限,ente 才能加密和保存文件", + "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "仅当您授予文件访问权限时,Ente 才能加密和保存文件", "pleaseGrantPermissions": "请授予权限", "grantPermission": "授予权限", - "privateSharing": "私人共享", + "privateSharing": "私人分享", "shareOnlyWithThePeopleYouWant": "仅与您想要的人分享", - "usePublicLinksForPeopleNotOnEnte": "为不在ente 上的人使用公共链接", + "usePublicLinksForPeopleNotOnEnte": "对不在 Ente 上的人使用公开链接", "allowPeopleToAddPhotos": "允许人们添加照片", "shareAnAlbumNow": "立即分享相册", "collectEventPhotos": "收集活动照片", @@ -695,7 +695,7 @@ }, "onDevice": "在设备上", "@onEnte": { - "description": "The text displayed above albums backed up to ente", + "description": "The text displayed above albums backed up to Ente", "type": "text" }, "onEnte": "在 ente 上", @@ -741,7 +741,7 @@ "saveCollage": "保存拼贴", "collageSaved": "拼贴已保存到相册", "collageLayout": "布局", - "addToEnte": "添加到 ente", + "addToEnte": "添加到 Ente", "addToAlbum": "添加到相册", "delete": "删除", "hide": "隐藏", @@ -806,10 +806,10 @@ "photosAddedByYouWillBeRemovedFromTheAlbum": "您添加的照片将从相册中移除", "youveNoFilesInThisAlbumThatCanBeDeleted": "您在此相册中没有可以删除的文件", "youDontHaveAnyArchivedItems": "您没有任何存档的项目。", - "ignoredFolderUploadReason": "此相册中的某些文件在上传时被忽略,因为它们之前已从 ente 中删除。", + "ignoredFolderUploadReason": "此相册中的某些文件在上传时会被忽略,因为它们之前已从 Ente 中删除。", "resetIgnoredFiles": "重置忽略的文件", - "deviceFilesAutoUploading": "添加到此设备相册的文件将自动上传到 ente。", - "turnOnBackupForAutoUpload": "打开备份以自动上传添加到此设备文件夹的文件。", + "deviceFilesAutoUploading": "添加到此设备相册的文件将自动上传到 Ente。", + "turnOnBackupForAutoUpload": "打开备份可自动上传添加到此设备文件夹的文件至 Ente。", "noHiddenPhotosOrVideos": "没有隐藏的照片或视频", "toHideAPhotoOrVideo": "隐藏照片或视频", "openTheItem": "• 打开该项目", @@ -886,7 +886,7 @@ "@freeUpSpaceSaving": { "description": "Text to tell user how much space they can free up by deleting items from the device" }, - "freeUpAccessPostDelete": "只要您有有效的订阅,您仍然可以在 ente 上访问 {count, plural, one {it} other {them}}", + "freeUpAccessPostDelete": "只要您有有效的订阅,您仍然可以在 Ente 上访问 {count, plural, one {它} other {它们}}", "@freeUpAccessPostDelete": { "placeholders": { "count": { @@ -904,15 +904,15 @@ "authenticationSuccessful": "验证成功", "incorrectRecoveryKey": "不正确的恢复密钥", "theRecoveryKeyYouEnteredIsIncorrect": "您输入的恢复密钥不正确", - "twofactorAuthenticationSuccessfullyReset": "成功重置双因素认证", + "twofactorAuthenticationSuccessfullyReset": "成功重置双重认证", "pleaseVerifyTheCodeYouHaveEntered": "请验证您输入的代码", "pleaseContactSupportIfTheProblemPersists": "如果问题仍然存在,请联系支持", - "twofactorAuthenticationHasBeenDisabled": "双因素认证已被禁用", + "twofactorAuthenticationHasBeenDisabled": "双重认证已被禁用", "sorryTheCodeYouveEnteredIsIncorrect": "抱歉,您输入的代码不正确", "yourVerificationCodeHasExpired": "您的验证码已过期", "emailChangedTo": "电子邮件已更改为 {newEmail}", "verifying": "正在验证...", - "disablingTwofactorAuthentication": "正在禁用双因素认证...", + "disablingTwofactorAuthentication": "正在禁用双重认证...", "allMemoriesPreserved": "所有回忆都已保存", "loadingGallery": "正在加载图库...", "syncing": "正在同步···", @@ -937,7 +937,7 @@ "renameFile": "重命名文件", "enterFileName": "请输入文件名", "filesDeleted": "文件已删除", - "selectedFilesAreNotOnEnte": "所选文件不在ente上", + "selectedFilesAreNotOnEnte": "所选文件不在 Ente 上", "thisActionCannotBeUndone": "此操作无法撤销", "emptyTrash": "要清空回收站吗?", "permDeleteWarning": "回收站中的所有项目将被永久删除\n\n此操作无法撤消", @@ -946,7 +946,7 @@ "permanentlyDeleteFromDevice": "要从设备中永久删除吗?", "someOfTheFilesYouAreTryingToDeleteAre": "您要删除的部分文件仅在您的设备上可用,且删除后无法恢复", "theyWillBeDeletedFromAllAlbums": "他们将从所有相册中删除。", - "someItemsAreInBothEnteAndYourDevice": "有些项目既在ente 也在您的设备中。", + "someItemsAreInBothEnteAndYourDevice": "有些项目同时存在于 Ente 和您的设备中。", "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "所选项目将从所有相册中删除并移动到回收站。", "theseItemsWillBeDeletedFromYourDevice": "这些项目将从您的设备中删除。", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "看起来出了点问题。 请稍后重试。 如果错误仍然存在,请联系我们的支持团队。", @@ -1052,7 +1052,7 @@ }, "setRadius": "设定半径", "familyPlanPortalTitle": "家庭", - "familyPlanOverview": "在您现有的计划中添加 5 名家庭成员而无需支付额外费用。\n\n每个成员都有自己的私人空间,除非共享,否则无法看到彼此的文件。\n\n家庭计划适用于已有付费订阅的客户。\n\n立即订阅以开始使用!", + "familyPlanOverview": "将 5 名家庭成员添加到您现有的计划中,无需支付额外费用。\n\n每个成员都有自己的私人空间,除非共享,否则无法看到彼此的文件。\n\n家庭计划适用于已付费 Ente 订阅的客户。\n\n立即订阅,开始体验!", "androidBiometricHint": "验证身份", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -1130,7 +1130,7 @@ "noAlbumsSharedByYouYet": "您尚未共享任何相册", "sharedWithYou": "已与您共享", "sharedByYou": "您共享的", - "inviteYourFriendsToEnte": "邀请您的好友加入ente", + "inviteYourFriendsToEnte": "邀请您的朋友加入 Ente", "failedToDownloadVideo": "视频下载失败", "hiding": "正在隐藏...", "unhiding": "正在取消隐藏...", @@ -1140,7 +1140,7 @@ "addToHiddenAlbum": "添加到隐藏相册", "moveToHiddenAlbum": "移至隐藏相册", "fileTypes": "文件类型", - "deleteConfirmDialogBody": "此账户已链接到其他 ente 旗下的应用程序(如果您使用任何 ente 旗下的应用程序)。\\n\\n您在所有 ente 旗下的应用程序中上传的数据将被安排删除,并且您的账户将被永久删除。", + "deleteConfirmDialogBody": "此账户已链接到其他 Ente 应用程序(如果您使用任何应用程序)。您在所有 Ente 应用程序中上传的数据将被安排删除,并且您的账户将被永久删除。", "hearUsWhereTitle": "您是如何知道Ente的? (可选的)", "hearUsExplanation": "我们不跟踪应用程序安装情况。如果您告诉我们您是在哪里找到我们的,将会有所帮助!", "viewAddOnButton": "查看附加组件", @@ -1204,5 +1204,12 @@ "addViewers": "{count, plural, zero {添加查看者} one {添加查看者} other {添加查看者}}", "addCollaborators": "{count, plural, zero {添加协作者} one {添加协作者} other {添加协作者}}", "longPressAnEmailToVerifyEndToEndEncryption": "长按电子邮件以验证端到端加密。", - "createCollaborativeLink": "Create collaborative link" + "developerSettingsWarning": "您确定要修改开发者设置吗?", + "developerSettings": "开发者设置", + "serverEndpoint": "服务器端点", + "invalidEndpoint": "端点无效", + "invalidEndpointMessage": "抱歉,您输入的端点无效。请输入有效的端点,然后重试。", + "endpointUpdatedMessage": "端点更新成功", + "customEndpoint": "已连接至 {endpoint}", + "createCollaborativeLink": "创建协作链接" } \ No newline at end of file diff --git a/web/apps/accounts/src/pages/_app.tsx b/web/apps/accounts/src/pages/_app.tsx index 2aed14e480..40a4a14588 100644 --- a/web/apps/accounts/src/pages/_app.tsx +++ b/web/apps/accounts/src/pages/_app.tsx @@ -1,5 +1,6 @@ import { CustomHead } from "@/next/components/Head"; import { setupI18n } from "@/next/i18n"; +import { logUnhandledErrorsAndRejections } from "@/next/log-web"; import { APPS, APP_TITLES } from "@ente/shared/apps/constants"; import { Overlay } from "@ente/shared/components/Container"; import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; @@ -54,6 +55,8 @@ export default function App({ Component, pageProps }: AppProps) { useEffect(() => { setupI18n().finally(() => setIsI18nReady(true)); + logUnhandledErrorsAndRejections(true); + return () => logUnhandledErrorsAndRejections(false); }, []); const setupPackageName = () => { diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index bd59ac2255..bf1093c907 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -1,6 +1,9 @@ import { CustomHead } from "@/next/components/Head"; import { setupI18n } from "@/next/i18n"; -import { logStartupBanner } from "@/next/log-web"; +import { + logStartupBanner, + logUnhandledErrorsAndRejections, +} from "@/next/log-web"; import { APPS, APP_TITLES, @@ -68,9 +71,11 @@ export default function App({ Component, pageProps }: AppProps) { setupI18n().finally(() => setIsI18nReady(true)); const userId = (getData(LS_KEYS.USER) as User)?.id; logStartupBanner(APPS.AUTH, userId); + logUnhandledErrorsAndRejections(true); HTTPService.setHeaders({ "X-Client-Package": CLIENT_PACKAGE_NAMES.get(APPS.AUTH), }); + return () => logUnhandledErrorsAndRejections(false); }, []); const setUserOnline = () => setOffline(false); diff --git a/web/apps/cast/src/pages/_app.tsx b/web/apps/cast/src/pages/_app.tsx index 3f22f687c8..99b047d416 100644 --- a/web/apps/cast/src/pages/_app.tsx +++ b/web/apps/cast/src/pages/_app.tsx @@ -1,12 +1,20 @@ import { CustomHead } from "@/next/components/Head"; +import { logUnhandledErrorsAndRejections } from "@/next/log-web"; import { APPS, APP_TITLES } from "@ente/shared/apps/constants"; import { getTheme } from "@ente/shared/themes"; import { THEME_COLOR } from "@ente/shared/themes/constants"; import { CssBaseline, ThemeProvider } from "@mui/material"; import type { AppProps } from "next/app"; +import { useEffect } from "react"; + import "styles/global.css"; export default function App({ Component, pageProps }: AppProps) { + useEffect(() => { + logUnhandledErrorsAndRejections(true); + return () => logUnhandledErrorsAndRejections(false); + }, []); + return ( <> diff --git a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx index 6668fef1fa..817aecb2bf 100644 --- a/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx +++ b/web/apps/photos/src/components/Sidebar/AdvancedSettings.tsx @@ -14,7 +14,7 @@ import { EnteMenuItem } from "components/Menu/EnteMenuItem"; import { MenuItemGroup } from "components/Menu/MenuItemGroup"; import isElectron from "is-electron"; import { AppContext } from "pages/_app"; -import { ClipExtractionStatus, ClipService } from "services/clipService"; +import { CLIPIndexingStatus, clipService } from "services/clip-service"; import { formatNumber } from "utils/number/format"; export default function AdvancedSettings({ open, onClose, onRootClose }) { @@ -44,17 +44,15 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) { log.error("toggleFasterUpload failed", e); } }; - const [indexingStatus, setIndexingStatus] = useState({ + const [indexingStatus, setIndexingStatus] = useState({ indexed: 0, pending: 0, }); useEffect(() => { - const main = async () => { - setIndexingStatus(await ClipService.getIndexingStatus()); - ClipService.setOnUpdateHandler(setIndexingStatus); - }; - main(); + clipService.setOnUpdateHandler(setIndexingStatus); + clipService.getIndexingStatus().then((st) => setIndexingStatus(st)); + return () => clipService.setOnUpdateHandler(undefined); }, []); return ( diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 4594b2e20a..06961d6c9b 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -1,7 +1,10 @@ import { CustomHead } from "@/next/components/Head"; import { setupI18n } from "@/next/i18n"; import log from "@/next/log"; -import { logStartupBanner } from "@/next/log-web"; +import { + logStartupBanner, + logUnhandledErrorsAndRejections, +} from "@/next/log-web"; import { AppUpdateInfo } from "@/next/types/ipc"; import { APPS, @@ -147,35 +150,35 @@ export default function App({ Component, pageProps }: AppProps) { setupI18n().finally(() => setIsI18nReady(true)); const userId = (getData(LS_KEYS.USER) as User)?.id; logStartupBanner(APPS.PHOTOS, userId); + logUnhandledErrorsAndRejections(true); HTTPService.setHeaders({ "X-Client-Package": CLIENT_PACKAGE_NAMES.get(APPS.PHOTOS), }); + return () => logUnhandledErrorsAndRejections(false); }, []); useEffect(() => { const electron = globalThis.electron; - if (electron) { - const showUpdateDialog = (updateInfo: AppUpdateInfo) => { - if (updateInfo.autoUpdatable) { - setDialogMessage( - getUpdateReadyToInstallMessage(updateInfo), - ); - } else { - setNotificationAttributes({ - endIcon: , - variant: "secondary", - message: t("UPDATE_AVAILABLE"), - onClick: () => - setDialogMessage( - getUpdateAvailableForDownloadMessage( - updateInfo, - ), - ), - }); - } - }; - electron.registerUpdateEventListener(showUpdateDialog); - } + if (!electron) return; + + const showUpdateDialog = (updateInfo: AppUpdateInfo) => { + if (updateInfo.autoUpdatable) { + setDialogMessage(getUpdateReadyToInstallMessage(updateInfo)); + } else { + setNotificationAttributes({ + endIcon: , + variant: "secondary", + message: t("UPDATE_AVAILABLE"), + onClick: () => + setDialogMessage( + getUpdateAvailableForDownloadMessage(updateInfo), + ), + }); + } + }; + electron.onAppUpdateAvailable(showUpdateDialog); + + return () => electron.onAppUpdateAvailable(undefined); }, []); useEffect(() => { diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index 69b833802b..bdbccedfba 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery/index.tsx @@ -102,7 +102,7 @@ import { } from "constants/collection"; import { SYNC_INTERVAL_IN_MICROSECONDS } from "constants/gallery"; import { AppContext } from "pages/_app"; -import { ClipService } from "services/clipService"; +import { clipService } from "services/clip-service"; import { constructUserIDToEmailMap } from "services/collectionService"; import downloadManager from "services/download"; import { syncEmbeddings } from "services/embeddingService"; @@ -362,18 +362,16 @@ export default function Gallery() { syncWithRemote(false, true); }, SYNC_INTERVAL_IN_MICROSECONDS); if (electron) { - void ClipService.setupOnFileUploadListener(); - electron.registerForegroundEventListener(() => { - syncWithRemote(false, true); - }); + void clipService.setupOnFileUploadListener(); + electron.onMainWindowFocus(() => syncWithRemote(false, true)); } }; main(); return () => { clearInterval(syncInterval.current); if (electron) { - electron.registerForegroundEventListener(() => {}); - ClipService.removeOnFileUploadListener(); + electron.onMainWindowFocus(undefined); + clipService.removeOnFileUploadListener(); } }; }, []); @@ -704,8 +702,8 @@ export default function Gallery() { await syncEntities(); await syncMapEnabled(); await syncEmbeddings(); - if (ClipService.isPlatformSupported()) { - void ClipService.scheduleImageEmbeddingExtraction(); + if (clipService.isPlatformSupported()) { + void clipService.scheduleImageEmbeddingExtraction(); } } catch (e) { switch (e.message) { diff --git a/web/apps/photos/src/pages/index.tsx b/web/apps/photos/src/pages/index.tsx index f290200866..760549bdbe 100644 --- a/web/apps/photos/src/pages/index.tsx +++ b/web/apps/photos/src/pages/index.tsx @@ -133,9 +133,9 @@ export default function LandingPage() { const electron = globalThis.electron; if (!key && electron) { try { - key = await electron.getEncryptionKey(); + key = await electron.encryptionKey(); } catch (e) { - log.error("getEncryptionKey failed", e); + log.error("Failed to get encryption key from electron", e); } if (key) { await saveKeyInSessionStore( diff --git a/web/apps/photos/src/services/clipService.ts b/web/apps/photos/src/services/clip-service.ts similarity index 72% rename from web/apps/photos/src/services/clipService.ts rename to web/apps/photos/src/services/clip-service.ts index 53e026d4ff..253d8097ec 100644 --- a/web/apps/photos/src/services/clipService.ts +++ b/web/apps/photos/src/services/clip-service.ts @@ -7,29 +7,70 @@ import { LS_KEYS, getData } from "@ente/shared/storage/localStorage"; import { FILE_TYPE } from "constants/file"; import isElectron from "is-electron"; import PQueue from "p-queue"; -import { Embedding, Model } from "types/embedding"; +import { Embedding } from "types/embedding"; import { EnteFile } from "types/file"; import { getPersonalFiles } from "utils/file"; import downloadManager from "./download"; import { getLocalEmbeddings, putEmbedding } from "./embeddingService"; import { getAllLocalFiles, getLocalFiles } from "./fileService"; -const CLIP_EMBEDDING_LENGTH = 512; - -export interface ClipExtractionStatus { +/** Status of CLIP indexing on the images in the user's local library. */ +export interface CLIPIndexingStatus { + /** Number of items pending indexing. */ pending: number; + /** Number of items that have already been indexed. */ indexed: number; } -class ClipServiceImpl { +/** + * Use a CLIP based neural network for natural language search. + * + * [Note: CLIP based magic search] + * + * CLIP (Contrastive Language-Image Pretraining) is a neural network trained on + * (image, text) pairs. It can be thought of as two separate (but jointly + * trained) encoders - one for images, and one for text - that both map to the + * same embedding space. + * + * We use this for natural language search within the app (aka "magic search"): + * + * 1. Pre-compute an embedding for each image. + * + * 2. When the user searches, compute an embedding for the search term. + * + * 3. Use cosine similarity to find the find the image (embedding) closest to + * the text (embedding). + * + * More details are in our [blog + * post](https://ente.io/blog/image-search-with-clip-ggml/) that describes the + * initial launch of this feature using the GGML runtime. + * + * Since the initial launch, we've switched over to another runtime, + * [ONNX](https://onnxruntime.ai). + * + * Note that we don't train the neural network - we only use one of the publicly + * available pre-trained neural networks for inference. These neural networks + * are wholly defined by their connectivity and weights. ONNX, our ML runtimes, + * loads these weights and instantiates a running network that we can use to + * compute the embeddings. + * + * Theoretically, the same CLIP model can be loaded by different frameworks / + * runtimes, but in practice each runtime has its own preferred format, and + * there are also quantization tradeoffs. So there is a specific model (a binary + * encoding of weights) tied to our current runtime that we use. + * + * To ensure that the embeddings, for the most part, can be shared, whenever + * possible we try to ensure that all the preprocessing steps, and the model + * itself, is the same across clients - web and mobile. + */ +class CLIPService { private embeddingExtractionInProgress: AbortController | null = null; private reRunNeeded = false; - private clipExtractionStatus: ClipExtractionStatus = { + private indexingStatus: CLIPIndexingStatus = { pending: 0, indexed: 0, }; - private onUpdateHandler: ((status: ClipExtractionStatus) => void) | null = - null; + private onUpdateHandler: ((status: CLIPIndexingStatus) => void) | undefined; private liveEmbeddingExtractionQueue: PQueue; private onFileUploadedHandler: | ((arg: { enteFile: EnteFile; localFile: globalThis.File }) => void) @@ -96,28 +137,23 @@ class ClipServiceImpl { }; getIndexingStatus = async () => { - try { - if ( - !this.clipExtractionStatus || - (this.clipExtractionStatus.pending === 0 && - this.clipExtractionStatus.indexed === 0) - ) { - this.clipExtractionStatus = await getClipExtractionStatus(); - } - return this.clipExtractionStatus; - } catch (e) { - log.error("failed to get clip indexing status", e); + if ( + this.indexingStatus.pending === 0 && + this.indexingStatus.indexed === 0 + ) { + this.indexingStatus = await initialIndexingStatus(); } + return this.indexingStatus; }; - setOnUpdateHandler = (handler: (status: ClipExtractionStatus) => void) => { + /** + * Set the {@link handler} to invoke whenever our indexing status changes. + */ + setOnUpdateHandler = (handler?: (status: CLIPIndexingStatus) => void) => { this.onUpdateHandler = handler; - handler(this.clipExtractionStatus); }; - scheduleImageEmbeddingExtraction = async ( - model: Model = Model.ONNX_CLIP, - ) => { + scheduleImageEmbeddingExtraction = async () => { try { if (this.embeddingExtractionInProgress) { log.info( @@ -133,7 +169,7 @@ class ClipServiceImpl { const canceller = new AbortController(); this.embeddingExtractionInProgress = canceller; try { - await this.runClipEmbeddingExtraction(canceller, model); + await this.runClipEmbeddingExtraction(canceller); } finally { this.embeddingExtractionInProgress = null; if (!canceller.signal.aborted && this.reRunNeeded) { @@ -152,25 +188,19 @@ class ClipServiceImpl { } }; - getTextEmbedding = async ( - text: string, - model: Model = Model.ONNX_CLIP, - ): Promise => { + getTextEmbedding = async (text: string): Promise => { try { - return ensureElectron().computeTextEmbedding(model, text); + return ensureElectron().clipTextEmbedding(text); } catch (e) { if (e?.message?.includes(CustomError.UNSUPPORTED_PLATFORM)) { this.unsupportedPlatform = true; } - log.error("failed to compute text embedding", e); + log.error("Failed to compute CLIP text embedding", e); throw e; } }; - private runClipEmbeddingExtraction = async ( - canceller: AbortController, - model: Model, - ) => { + private runClipEmbeddingExtraction = async (canceller: AbortController) => { try { if (this.unsupportedPlatform) { log.info( @@ -183,12 +213,12 @@ class ClipServiceImpl { return; } const localFiles = getPersonalFiles(await getAllLocalFiles(), user); - const existingEmbeddings = await getLocalEmbeddings(model); + const existingEmbeddings = await getLocalEmbeddings(); const pendingFiles = await getNonClipEmbeddingExtractedFiles( localFiles, existingEmbeddings, ); - this.updateClipEmbeddingExtractionStatus({ + this.updateIndexingStatus({ indexed: existingEmbeddings.length, pending: pendingFiles.length, }); @@ -208,15 +238,11 @@ class ClipServiceImpl { throw Error(CustomError.REQUEST_CANCELLED); } const embeddingData = - await this.extractFileClipImageEmbedding(model, file); + await this.extractFileClipImageEmbedding(file); log.info( `successfully extracted clip embedding for file: ${file.metadata.title} fileID: ${file.id} embedding length: ${embeddingData?.length}`, ); - await this.encryptAndUploadEmbedding( - model, - file, - embeddingData, - ); + await this.encryptAndUploadEmbedding(file, embeddingData); this.onSuccessStatusUpdater(); log.info( `successfully put clip embedding to server for file: ${file.metadata.title} fileID: ${file.id}`, @@ -249,13 +275,10 @@ class ClipServiceImpl { } }; - private async runLocalFileClipExtraction( - arg: { - enteFile: EnteFile; - localFile: globalThis.File; - }, - model: Model = Model.ONNX_CLIP, - ) { + private async runLocalFileClipExtraction(arg: { + enteFile: EnteFile; + localFile: globalThis.File; + }) { const { enteFile, localFile } = arg; log.info( `clip embedding extraction onFileUploadedHandler file: ${enteFile.metadata.title} fileID: ${enteFile.id}`, @@ -279,15 +302,9 @@ class ClipServiceImpl { ); try { await this.liveEmbeddingExtractionQueue.add(async () => { - const embedding = await this.extractLocalFileClipImageEmbedding( - model, - localFile, - ); - await this.encryptAndUploadEmbedding( - model, - enteFile, - embedding, - ); + const embedding = + await this.extractLocalFileClipImageEmbedding(localFile); + await this.encryptAndUploadEmbedding(enteFile, embedding); }); log.info( `successfully extracted clip embedding for file: ${enteFile.metadata.title} fileID: ${enteFile.id}`, @@ -297,26 +314,18 @@ class ClipServiceImpl { } } - private extractLocalFileClipImageEmbedding = async ( - model: Model, - localFile: File, - ) => { + private extractLocalFileClipImageEmbedding = async (localFile: File) => { const file = await localFile .arrayBuffer() .then((buffer) => new Uint8Array(buffer)); - const embedding = await ensureElectron().computeImageEmbedding( - model, - file, - ); - return embedding; + return await ensureElectron().clipImageEmbedding(file); }; private encryptAndUploadEmbedding = async ( - model: Model, file: EnteFile, embeddingData: Float32Array, ) => { - if (embeddingData?.length !== CLIP_EMBEDDING_LENGTH) { + if (embeddingData?.length !== 512) { throw Error( `invalid length embedding data length: ${embeddingData?.length}`, ); @@ -331,38 +340,31 @@ class ClipServiceImpl { fileID: file.id, encryptedEmbedding: encryptedEmbeddingData.encryptedData, decryptionHeader: encryptedEmbeddingData.decryptionHeader, - model, + model: "onnx-clip", }); }; - updateClipEmbeddingExtractionStatus = (status: ClipExtractionStatus) => { - this.clipExtractionStatus = status; - if (this.onUpdateHandler) { - this.onUpdateHandler(status); - } + private updateIndexingStatus = (status: CLIPIndexingStatus) => { + this.indexingStatus = status; + const handler = this.onUpdateHandler; + if (handler) handler(status); }; - private extractFileClipImageEmbedding = async ( - model: Model, - file: EnteFile, - ) => { + private extractFileClipImageEmbedding = async (file: EnteFile) => { const thumb = await downloadManager.getThumbnail(file); - const embedding = await ensureElectron().computeImageEmbedding( - model, - thumb, - ); + const embedding = await ensureElectron().clipImageEmbedding(thumb); return embedding; }; private onSuccessStatusUpdater = () => { - this.updateClipEmbeddingExtractionStatus({ - pending: this.clipExtractionStatus.pending - 1, - indexed: this.clipExtractionStatus.indexed + 1, + this.updateIndexingStatus({ + pending: this.indexingStatus.pending - 1, + indexed: this.indexingStatus.indexed + 1, }); }; } -export const ClipService = new ClipServiceImpl(); +export const clipService = new CLIPService(); const getNonClipEmbeddingExtractedFiles = async ( files: EnteFile[], @@ -412,14 +414,10 @@ export const computeClipMatchScore = async ( return score; }; -const getClipExtractionStatus = async ( - model: Model = Model.ONNX_CLIP, -): Promise => { +const initialIndexingStatus = async (): Promise => { const user = getData(LS_KEYS.USER); - if (!user) { - return; - } - const allEmbeddings = await getLocalEmbeddings(model); + if (!user) throw new Error("Orphan CLIP indexing without a login"); + const allEmbeddings = await getLocalEmbeddings(); const localFiles = getPersonalFiles(await getLocalFiles(), user); const pendingFiles = await getNonClipEmbeddingExtractedFiles( localFiles, diff --git a/web/apps/photos/src/services/embeddingService.ts b/web/apps/photos/src/services/embeddingService.ts index 882cdd16c9..c4c0075c69 100644 --- a/web/apps/photos/src/services/embeddingService.ts +++ b/web/apps/photos/src/services/embeddingService.ts @@ -5,11 +5,11 @@ import HTTPService from "@ente/shared/network/HTTPService"; import { getEndpoint } from "@ente/shared/network/api"; import localForage from "@ente/shared/storage/localForage"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; -import { +import type { Embedding, + EmbeddingModel, EncryptedEmbedding, GetEmbeddingDiffResponse, - Model, PutEmbeddingRequest, } from "types/embedding"; import { EnteFile } from "types/file"; @@ -38,12 +38,12 @@ export const getAllLocalEmbeddings = async () => { return embeddings; }; -export const getLocalEmbeddings = async (model: Model) => { +export const getLocalEmbeddings = async () => { const embeddings = await getAllLocalEmbeddings(); - return embeddings.filter((embedding) => embedding.model === model); + return embeddings.filter((embedding) => embedding.model === "onnx-clip"); }; -const getModelEmbeddingSyncTime = async (model: Model) => { +const getModelEmbeddingSyncTime = async (model: EmbeddingModel) => { return ( (await localForage.getItem( `${model}-${EMBEDDING_SYNC_TIME_TABLE}`, @@ -51,11 +51,15 @@ const getModelEmbeddingSyncTime = async (model: Model) => { ); }; -const setModelEmbeddingSyncTime = async (model: Model, time: number) => { +const setModelEmbeddingSyncTime = async ( + model: EmbeddingModel, + time: number, +) => { await localForage.setItem(`${model}-${EMBEDDING_SYNC_TIME_TABLE}`, time); }; -export const syncEmbeddings = async (models: Model[] = [Model.ONNX_CLIP]) => { +export const syncEmbeddings = async () => { + const models: EmbeddingModel[] = ["onnx-clip"]; try { let allEmbeddings = await getAllLocalEmbeddings(); const localFiles = await getAllLocalFiles(); @@ -138,7 +142,7 @@ export const syncEmbeddings = async (models: Model[] = [Model.ONNX_CLIP]) => { export const getEmbeddingsDiff = async ( sinceTime: number, - model: Model, + model: EmbeddingModel, ): Promise => { try { const token = getToken(); diff --git a/web/apps/photos/src/services/searchService.ts b/web/apps/photos/src/services/searchService.ts index 692b4ac843..b85005db08 100644 --- a/web/apps/photos/src/services/searchService.ts +++ b/web/apps/photos/src/services/searchService.ts @@ -4,7 +4,6 @@ import * as chrono from "chrono-node"; import { FILE_TYPE } from "constants/file"; import { t } from "i18next"; import { Collection } from "types/collection"; -import { Model } from "types/embedding"; import { EntityType, LocationTag, LocationTagData } from "types/entity"; import { EnteFile } from "types/file"; import { Person, Thing } from "types/machineLearning"; @@ -22,7 +21,7 @@ import { getAllPeople } from "utils/machineLearning"; import { getMLSyncConfig } from "utils/machineLearning/config"; import { getFormattedDate } from "utils/search"; import mlIDbStorage from "utils/storage/mlIDbStorage"; -import { ClipService, computeClipMatchScore } from "./clipService"; +import { clipService, computeClipMatchScore } from "./clip-service"; import { getLocalEmbeddings } from "./embeddingService"; import { getLatestEntities } from "./entityService"; import locationSearchService, { City } from "./locationSearchService"; @@ -305,7 +304,7 @@ async function getThingSuggestion(searchPhrase: string): Promise { async function getClipSuggestion(searchPhrase: string): Promise { try { - if (!ClipService.isPlatformSupported()) { + if (!clipService.isPlatformSupported()) { return null; } @@ -396,8 +395,8 @@ async function searchThing(searchPhrase: string) { } async function searchClip(searchPhrase: string): Promise { - const imageEmbeddings = await getLocalEmbeddings(Model.ONNX_CLIP); - const textEmbedding = await ClipService.getTextEmbedding(searchPhrase); + const imageEmbeddings = await getLocalEmbeddings(); + const textEmbedding = await clipService.getTextEmbedding(searchPhrase); const clipSearchResult = new Map( ( await Promise.all( diff --git a/web/apps/photos/src/types/embedding.tsx b/web/apps/photos/src/types/embedding.tsx index 3626e0fadb..c0014d01e8 100644 --- a/web/apps/photos/src/types/embedding.tsx +++ b/web/apps/photos/src/types/embedding.tsx @@ -1,11 +1,16 @@ -export enum Model { - GGML_CLIP = "ggml-clip", - ONNX_CLIP = "onnx-clip", -} +/** + * The embeddings models that we support. + * + * This is an exhaustive set of values we pass when PUT-ting encrypted + * embeddings on the server. However, we should be prepared to receive an + * {@link EncryptedEmbedding} with a model value distinct from one of these. + */ +export type EmbeddingModel = "onnx-clip"; export interface EncryptedEmbedding { fileID: number; - model: Model; + /** @see {@link EmbeddingModel} */ + model: string; encryptedEmbedding: string; decryptionHeader: string; updatedAt: number; @@ -25,7 +30,7 @@ export interface GetEmbeddingDiffResponse { export interface PutEmbeddingRequest { fileID: number; - model: Model; + model: EmbeddingModel; encryptedEmbedding: string; decryptionHeader: string; } diff --git a/web/apps/photos/src/utils/ui/index.tsx b/web/apps/photos/src/utils/ui/index.tsx index 9090c6917d..1b01116d33 100644 --- a/web/apps/photos/src/utils/ui/index.tsx +++ b/web/apps/photos/src/utils/ui/index.tsx @@ -1,3 +1,4 @@ +import { ensureElectron } from "@/next/electron"; import { AppUpdateInfo } from "@/next/types/ipc"; import { logoutUser } from "@ente/accounts/services/user"; import { DialogBoxAttributes } from "@ente/shared/components/DialogBox/types"; @@ -52,35 +53,34 @@ export const getTrashFileMessage = (deleteFileHelper): DialogBoxAttributes => ({ close: { text: t("CANCEL") }, }); -export const getUpdateReadyToInstallMessage = ( - updateInfo: AppUpdateInfo, -): DialogBoxAttributes => ({ +export const getUpdateReadyToInstallMessage = ({ + version, +}: AppUpdateInfo): DialogBoxAttributes => ({ icon: , title: t("UPDATE_AVAILABLE"), content: t("UPDATE_INSTALLABLE_MESSAGE"), proceed: { - action: () => globalThis.electron?.updateAndRestart(), + action: () => ensureElectron().updateAndRestart(), text: t("INSTALL_NOW"), variant: "accent", }, close: { text: t("INSTALL_ON_NEXT_LAUNCH"), variant: "secondary", - action: () => - globalThis.electron?.muteUpdateNotification(updateInfo.version), + action: () => ensureElectron().updateOnNextRestart(version), }, }); -export const getUpdateAvailableForDownloadMessage = ( - updateInfo: AppUpdateInfo, -): DialogBoxAttributes => ({ +export const getUpdateAvailableForDownloadMessage = ({ + version, +}: AppUpdateInfo): DialogBoxAttributes => ({ icon: , title: t("UPDATE_AVAILABLE"), content: t("UPDATE_AVAILABLE_MESSAGE"), close: { text: t("IGNORE_THIS_VERSION"), variant: "secondary", - action: () => globalThis.electron?.skipAppUpdate(updateInfo.version), + action: () => ensureElectron().skipAppUpdate(version), }, proceed: { action: downloadApp, diff --git a/web/packages/accounts/api/user.ts b/web/packages/accounts/api/user.ts index 865a0c217c..7a072064e0 100644 --- a/web/packages/accounts/api/user.ts +++ b/web/packages/accounts/api/user.ts @@ -1,4 +1,3 @@ -import log from "@/next/log"; import { RecoveryKey, TwoFactorRecoveryResponse, @@ -62,7 +61,6 @@ export const _logout = async () => { ) { return; } - log.error("/users/logout failed", e); throw e; } }; diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index bf5943238a..3e8fbabbe6 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -70,9 +70,9 @@ export default function Credentials({ appContext, appName }: PageProps) { const electron = globalThis.electron; if (!key && electron) { try { - key = await electron.getEncryptionKey(); + key = await electron.encryptionKey(); } catch (e) { - log.error("getEncryptionKey failed", e); + log.error("Failed to get encryption key from electron", e); } if (key) { await saveKeyInSessionStore( diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 43d2f0883f..87a320e361 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -11,49 +11,44 @@ import { PAGES } from "../constants/pages"; export const logoutUser = async () => { try { - try { - await _logout(); - } catch (e) { - // ignore - } - try { - InMemoryStore.clear(); - } catch (e) { - // ignore - log.error("clear InMemoryStore failed", e); - } - try { - clearKeys(); - } catch (e) { - log.error("clearKeys failed", e); - } - try { - clearData(); - } catch (e) { - log.error("clearData failed", e); - } - try { - await deleteAllCache(); - } catch (e) { - log.error("deleteAllCache failed", e); - } - try { - await clearFiles(); - } catch (e) { - log.error("clearFiles failed", e); - } - try { - globalThis.electron?.clearElectronStore(); - } catch (e) { - log.error("clearElectronStore failed", e); - } - try { - eventBus.emit(Events.LOGOUT); - } catch (e) { - log.error("Error in logout handlers", e); - } - router.push(PAGES.ROOT); + await _logout(); } catch (e) { - log.error("logoutUser failed", e); + log.error("Ignoring error during POST /users/logout", e); } + try { + InMemoryStore.clear(); + } catch (e) { + log.error("Ignoring error when clearing in-memory store", e); + } + try { + clearKeys(); + } catch (e) { + log.error("Ignoring error when clearing keys", e); + } + try { + clearData(); + } catch (e) { + log.error("Ignoring error when clearing data", e); + } + try { + await deleteAllCache(); + } catch (e) { + log.error("Ignoring error when clearing caches", e); + } + try { + await clearFiles(); + } catch (e) { + log.error("Ignoring error when clearing files", e); + } + try { + globalThis.electron?.clearStores(); + } catch (e) { + log.error("Ignoring error when clearing electron stores", e); + } + try { + eventBus.emit(Events.LOGOUT); + } catch (e) { + log.error("Ignoring error in event-bus logout handlers", e); + } + router.push(PAGES.ROOT); }; diff --git a/web/packages/next/log-web.ts b/web/packages/next/log-web.ts index 093a2065ca..f319118ce6 100644 --- a/web/packages/next/log-web.ts +++ b/web/packages/next/log-web.ts @@ -18,6 +18,33 @@ export const logStartupBanner = (appId: string, userId?: number) => { log.info(`Starting ente-${appIdL}-web ${buildId}uid ${userId ?? 0}`); }; +/** + * Attach handlers to log any unhandled exceptions and promise rejections. + * + * @param attach If true, attach handlers, and if false, remove them. This + * allows us to use this in a React hook that cleans up after itself. + */ +export const logUnhandledErrorsAndRejections = (attach: boolean) => { + const handleError = (event: ErrorEvent) => { + log.error("Unhandled error", event.error); + }; + + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + log.error("Unhandled promise rejection", event.reason); + }; + + if (attach) { + window.addEventListener("error", handleError); + window.addEventListener("unhandledrejection", handleUnhandledRejection); + } else { + window.removeEventListener("error", handleError); + window.removeEventListener( + "unhandledrejection", + handleUnhandledRejection, + ); + } +}; + interface LogEntry { timestamp: number; logLine: string; diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index d13c775f40..a0bc07d9a8 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -10,11 +10,6 @@ export interface AppUpdateInfo { version: string; } -export enum Model { - GGML_CLIP = "ggml-clip", - ONNX_CLIP = "onnx-clip", -} - export enum FILE_PATH_TYPE { FILES = "files", ZIPS = "zips", @@ -42,9 +37,22 @@ export enum PICKED_UPLOAD_TYPE { export interface Electron { // - General - /** Return the version of the desktop app. */ + /** + * Return the version of the desktop app. + * + * The return value is of the form `v1.2.3`. + */ appVersion: () => Promise; + /** + * Log the given {@link message} to the on-disk log file maintained by the + * desktop app. + * + * Note: Unlike the other functions exposed over the Electron bridge, + * logToDisk is fire-and-forget and does not return a promise. + */ + logToDisk: (message: string) => void; + /** * Open the given {@link dirPath} in the system's folder viewer. * @@ -60,13 +68,75 @@ export interface Electron { openLogDirectory: () => Promise; /** - * Log the given {@link message} to the on-disk log file maintained by the - * desktop app. + * Clear any stored data. * - * Note: Unlike the other functions exposed over the Electron bridge, - * logToDisk is fire-and-forget and does not return a promise. + * This is a coarse single shot cleanup, meant for use in clearing any + * Electron side state during logout. */ - logToDisk: (message: string) => void; + clearStores: () => void; + + /** + * Return the previously saved encryption key from persistent safe storage. + * + * If no such key is found, return `undefined`. + * + * @see {@link saveEncryptionKey}. + */ + encryptionKey: () => Promise; + + /** + * Save the given {@link encryptionKey} into persistent safe storage. + */ + saveEncryptionKey: (encryptionKey: string) => Promise; + + /** + * Set or clear the callback {@link cb} to invoke whenever the app comes + * into the foreground. More precisely, the callback gets invoked when the + * main window gets focus. + * + * Note: Setting a callback clears any previous callbacks. + * + * @param cb The function to call when the main window gets focus. Pass + * `undefined` to clear the callback. + */ + onMainWindowFocus: (cb?: () => void) => void; + + // - App update + + /** + * Set or clear the callback {@link cb} to invoke whenever a new + * (actionable) app update is available. This allows the Node.js layer to + * ask the renderer to show an "Update available" dialog to the user. + * + * Note: Setting a callback clears any previous callbacks. + */ + onAppUpdateAvailable: ( + cb?: ((updateInfo: AppUpdateInfo) => void) | undefined, + ) => void; + + /** + * Restart the app to apply the latest available update. + * + * This is expected to be called in response to {@link onAppUpdateAvailable} + * if the user so wishes. + */ + updateAndRestart: () => void; + + /** + * Mute update notifications for the given {@link version}. This allows us + * to implement the "Install on next launch" functionality in response to + * the {@link onAppUpdateAvailable} event. + */ + updateOnNextRestart: (version: string) => void; + + /** + * Skip the app update with the given {@link version}. + * + * This is expected to be called in response to {@link onAppUpdateAvailable} + * if the user so wishes. It will remember this {@link version} as having + * been marked as skipped so that we don't prompt the user again. + */ + skipAppUpdate: (version: string) => void; /** * A subset of filesystem access APIs. @@ -103,28 +173,6 @@ export interface Electron { * the dataflow. */ - // - General - - registerForegroundEventListener: (onForeground: () => void) => void; - - clearElectronStore: () => void; - - setEncryptionKey: (encryptionKey: string) => Promise; - - getEncryptionKey: () => Promise; - - // - App update - - updateAndRestart: () => void; - - skipAppUpdate: (version: string) => void; - - muteUpdateNotification: (version: string) => void; - - registerUpdateEventListener: ( - showUpdateDialog: (updateInfo: AppUpdateInfo) => void, - ) => void; - // - Conversion convertToJPEG: ( @@ -147,12 +195,27 @@ export interface Electron { // - ML - computeImageEmbedding: ( - model: Model, - imageData: Uint8Array, - ) => Promise; + /** + * Compute and return a CLIP embedding of the given image. + * + * See: [Note: CLIP based magic search] + * + * @param jpegImageData The raw bytes of the image encoded as an JPEG. + * + * @returns A CLIP embedding. + */ + clipImageEmbedding: (jpegImageData: Uint8Array) => Promise; - computeTextEmbedding: (model: Model, text: string) => Promise; + /** + * Compute and return a CLIP embedding of the given image. + * + * See: [Note: CLIP based magic search] + * + * @param text The string whose embedding we want to compute. + * + * @returns A CLIP embedding. + */ + clipTextEmbedding: (text: string) => Promise; // - File selection // TODO: Deprecated - use dialogs on the renderer process itself diff --git a/web/packages/shared/crypto/helpers.ts b/web/packages/shared/crypto/helpers.ts index 9250b4ab78..89fc27840d 100644 --- a/web/packages/shared/crypto/helpers.ts +++ b/web/packages/shared/crypto/helpers.ts @@ -103,7 +103,7 @@ export const saveKeyInSessionStore = async ( setKey(keyType, sessionKeyAttributes); const electron = globalThis.electron; if (electron && !fromDesktop && keyType === SESSION_KEYS.ENCRYPTION_KEY) { - electron.setEncryptionKey(key); + electron.saveEncryptionKey(key); } };