Merge branch 'main' into use_sqlite_async_for_fetching_files_for_gallery

This commit is contained in:
ashilkn 2024-04-10 15:24:23 +05:30
commit 5879f5ed06
58 changed files with 1056 additions and 1075 deletions

View file

@ -24,4 +24,5 @@ startup_notify: false
# include:
# - libcurl.so.4
include:
- libffi.so.7
- libtiff.so.5

View file

@ -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:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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

View file

@ -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

View file

@ -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"),
);
};

View file

@ -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

View file

@ -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)}`);
}
};
/**

View file

@ -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(

View file

@ -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<string> => ipcRenderer.invoke("appVersion");
const logToDisk = (message: string): void =>
ipcRenderer.send("logToDisk", message);
const openDirectory = (dirPath: string): Promise<void> =>
ipcRenderer.invoke("openDirectory", dirPath);
const openLogDirectory = (): Promise<void> =>
ipcRenderer.invoke("openLogDirectory");
const logToDisk = (message: string): void =>
ipcRenderer.send("logToDisk", message);
const clearStores = () => ipcRenderer.send("clearStores");
const encryptionKey = (): Promise<string | undefined> =>
ipcRenderer.invoke("encryptionKey");
const saveEncryptionKey = (encryptionKey: string): Promise<void> =>
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<boolean> =>
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<void> =>
ipcRenderer.invoke("setEncryptionKey", encryptionKey);
const getEncryptionKey = (): Promise<string> =>
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<Float32Array> =>
ipcRenderer.invoke("computeImageEmbedding", model, imageData);
const clipImageEmbedding = (jpegImageData: Uint8Array): Promise<Float32Array> =>
ipcRenderer.invoke("clipImageEmbedding", jpegImageData);
const computeTextEmbedding = (
model: Model,
text: string,
): Promise<Float32Array> =>
ipcRenderer.invoke("computeTextEmbedding", model, text);
const clipTextEmbedding = (text: string): Promise<Float32Array> =>
ipcRenderer.invoke("clipTextEmbedding", text);
// - File selection
@ -310,21 +300,19 @@ const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
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,

View file

@ -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);

View file

@ -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);
}

View file

@ -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<void> | 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<any> | 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);
};

View file

@ -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<void> = 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<any> = 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<Float32Array> => {
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<Float32Array> {
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<Float32Array> => {
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<Float32Array> => {
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<Float32Array> {
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<Float32Array> {
switch (model) {
case "ggml-clip":
return await computeGGMLTextEmbedding(text);
case "onnx-clip":
return await computeONNXTextEmbedding(text);
}
}
export async function computeGGMLTextEmbedding(
text: string,
): Promise<Float32Array> {
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<Float32Array> {
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);
}

View file

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

View file

@ -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");
}

View file

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

View file

@ -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";

View file

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

View file

@ -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]

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1 @@
ente uçtan uca şifrelenmiş bir fotoğraf depolama uygulamasıdır

View file

@ -0,0 +1 @@
ente - şifrelenmiş depolama sistemi

View file

@ -1 +1 @@
ente Фото
ente фотографии

View file

@ -1 +1 @@
Система зашифрованного хранения фотографий
Зашифрованное хранилище фотографий

View file

@ -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

View file

@ -0,0 +1 @@
fotoğraflar,fotoğrafçılık,aile,gizlilik,bulut,yedekleme,videolar,fotoğraf,şifreleme,depolama,albüm,alternatif

View file

@ -0,0 +1 @@
ente fotoğraf uygulaması

View file

@ -0,0 +1 @@
Şifrelenmiş depolama sistemi

View file

@ -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.

View file

@ -0,0 +1 @@
Şifreli fotoğraf depolama - fotoğraflarınızı ve videolarınızı yedekleyin, düzenleyin ve paylaşın

View file

@ -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 <branding>ente</branding>",
@ -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"
}

View file

@ -23,7 +23,7 @@
"sendEmail": "发送电子邮件",
"deleteRequestSLAText": "您的请求将在 72 小时内处理。",
"deleteEmailRequest": "请从您注册的电子邮件地址发送电子邮件到 <warning>account-delettion@ente.io</warning>。",
"entePhotosPerm": "ente <i>需要许可</i>才能保存您的照片",
"entePhotosPerm": "Ente <i>需要许可</i>才能保存您的照片",
"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": "在 <branding>ente</branding> 上",
@ -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": "创建协作链接"
}

View file

@ -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 = () => {

View file

@ -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);

View file

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

View file

@ -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<ClipExtractionStatus>({
const [indexingStatus, setIndexingStatus] = useState<CLIPIndexingStatus>({
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 (

View file

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

View file

@ -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) {

View file

@ -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(

View file

@ -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<Float32Array> => {
getTextEmbedding = async (text: string): Promise<Float32Array> => {
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<ClipExtractionStatus> => {
const initialIndexingStatus = async (): Promise<CLIPIndexingStatus> => {
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,

View file

@ -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<number>(
`${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<GetEmbeddingDiffResponse> => {
try {
const token = getToken();

View file

@ -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<Suggestion[]> {
async function getClipSuggestion(searchPhrase: string): Promise<Suggestion> {
try {
if (!ClipService.isPlatformSupported()) {
if (!clipService.isPlatformSupported()) {
return null;
}
@ -396,8 +395,8 @@ async function searchThing(searchPhrase: string) {
}
async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
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<number, number>(
(
await Promise.all(

View file

@ -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;
}

View file

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

View file

@ -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;
}
};

View file

@ -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(

View file

@ -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);
};

View file

@ -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;

View file

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

View file

@ -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);
}
};