Merge branch 'main' into f-droid

This commit is contained in:
vishnukvmd
2024-04-16 11:41:19 +05:30
38 changed files with 785 additions and 435 deletions

View File

@@ -9,7 +9,7 @@
* https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
*/
import { nativeImage } from "electron";
import { app, BrowserWindow, Menu, Tray } from "electron/main";
import { app, BrowserWindow, Menu, protocol, Tray } from "electron/main";
import serveNextAt from "next-electron-server";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
@@ -27,12 +27,13 @@ import { setupAutoUpdater } from "./main/services/app-update";
import autoLauncher from "./main/services/autoLauncher";
import { initWatcher } from "./main/services/chokidar";
import { userPreferences } from "./main/stores/user-preferences";
import { registerStreamProtocol } from "./main/stream";
import { isDev } from "./main/util";
/**
* The URL where the renderer HTML is being served from.
*/
export const rendererURL = "next://app";
export const rendererURL = "ente://app";
/**
* We want to hide our window instead of closing it when the user presses the
@@ -58,21 +59,6 @@ export const allowWindowClose = (): void => {
shouldAllowWindowClose = true;
};
/**
* next-electron-server allows up to directly use the output of `next build` in
* production mode and `next dev` in development mode, whilst keeping the rest
* of our code the same.
*
* It uses protocol handlers to serve files from the "next://app" protocol
*
* - In development this is proxied to http://localhost:3000
* - In production it serves files from the `/out` directory
*
* For more details, see this comparison:
* https://github.com/HaNdTriX/next-electron-server/issues/5
*/
const setupRendererServer = () => serveNextAt(rendererURL);
/**
* Log a standard startup banner.
*
@@ -88,6 +74,75 @@ const logStartupBanner = () => {
log.info("Running on", { platform, osRelease, systemVersion });
};
/**
* next-electron-server allows up to directly use the output of `next build` in
* production mode and `next dev` in development mode, whilst keeping the rest
* of our code the same.
*
* It uses protocol handlers to serve files from the "ente://" protocol.
*
* - In development this is proxied to http://localhost:3000
* - In production it serves files from the `/out` directory
*
* For more details, see this comparison:
* https://github.com/HaNdTriX/next-electron-server/issues/5
*/
const setupRendererServer = () => serveNextAt(rendererURL);
/**
* Register privileged schemes.
*
* We have two privileged schemes:
*
* 1. "ente", used for serving our web app (@see {@link setupRendererServer}).
*
* 2. "stream", used for streaming IPC (@see {@link registerStreamProtocol}).
*
* Both of these need some privileges, however, the documentation for Electron's
* [registerSchemesAsPrivileged](https://www.electronjs.org/docs/latest/api/protocol)
* says:
*
* > This method ... can be called only once.
*
* The library we use for the "ente" scheme, next-electron-server, already calls
* it once when we invoke {@link setupRendererServer}.
*
* In practice calling it multiple times just causes the values to be
* overwritten, and the last call wins. So we don't need to modify
* next-electron-server to prevent it from calling registerSchemesAsPrivileged.
* Instead, we (a) repeat what next-electron-server had done here, and (b)
* ensure that we're called after {@link setupRendererServer}.
*/
const registerPrivilegedSchemes = () => {
protocol.registerSchemesAsPrivileged([
{
// Taken verbatim from next-electron-server's code (index.js)
scheme: "ente",
privileges: {
standard: true,
secure: true,
allowServiceWorkers: true,
supportFetchAPI: true,
corsEnabled: true,
},
},
{
scheme: "stream",
privileges: {
// TODO(MR): Remove the commented bits if we don't end up
// needing them by the time the IPC refactoring is done.
// Prevent the insecure origin issues when fetching this
// secure: true,
// Allow the web fetch API in the renderer to use this scheme.
supportFetchAPI: true,
// Allow it to be used with video tags.
// stream: true,
},
},
]);
};
/**
* [Note: Increased disk cache for the desktop app]
*
@@ -251,8 +306,10 @@ const main = () => {
let mainWindow: BrowserWindow | undefined;
initLogging();
setupRendererServer();
logStartupBanner();
// The order of the next two calls is important
setupRendererServer();
registerPrivilegedSchemes();
increaseDiskCache();
app.on("second-instance", () => {
@@ -269,11 +326,11 @@ const main = () => {
// Note that some Electron APIs can only be used after this event occurs.
app.on("ready", async () => {
mainWindow = await createMainWindow();
const watcher = initWatcher(mainWindow);
setupTrayItem(mainWindow);
Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
setupTrayItem(mainWindow);
attachIPCHandlers();
attachFSWatchIPCHandlers(watcher);
attachFSWatchIPCHandlers(initWatcher(mainWindow));
registerStreamProtocol();
if (!isDev) setupAutoUpdater(mainWindow);
handleDownloads(mainWindow);
handleExternalLinks(mainWindow);

View File

@@ -1,9 +1,8 @@
/**
* @file file system related functions exposed over the context bridge.
*/
import { createWriteStream, existsSync } from "node:fs";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { Readable } from "node:stream";
export const fsExists = (path: string) => existsSync(path);
@@ -17,78 +16,13 @@ export const fsRmdir = (path: string) => fs.rmdir(path);
export const fsRm = (path: string) => fs.rm(path);
/**
* Write a (web) ReadableStream to a file at the given {@link filePath}.
*
* The returned promise resolves when the write completes.
*
* @param filePath The local filesystem path where the file should be written.
* @param readableStream A [web
* ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
*/
export const writeStream = (filePath: string, readableStream: ReadableStream) =>
writeNodeStream(filePath, convertWebReadableStreamToNode(readableStream));
export const fsReadTextFile = async (filePath: string) =>
fs.readFile(filePath, "utf-8");
/**
* Convert a Web ReadableStream into a Node.js ReadableStream
*
* This can be used to, for example, write a ReadableStream obtained via
* `net.fetch` into a file using the Node.js `fs` APIs
*/
const convertWebReadableStreamToNode = (readableStream: ReadableStream) => {
const reader = readableStream.getReader();
const rs = new Readable();
rs._read = async () => {
try {
const result = await reader.read();
if (!result.done) {
rs.push(Buffer.from(result.value));
} else {
rs.push(null);
return;
}
} catch (e) {
rs.emit("error", e);
}
};
return rs;
};
const writeNodeStream = async (
filePath: string,
fileStream: NodeJS.ReadableStream,
) => {
const writeable = createWriteStream(filePath);
fileStream.on("error", (error) => {
writeable.destroy(error); // Close the writable stream with an error
});
fileStream.pipe(writeable);
await new Promise((resolve, reject) => {
writeable.on("finish", resolve);
writeable.on("error", async (e: unknown) => {
if (existsSync(filePath)) {
await fs.unlink(filePath);
}
reject(e);
});
});
};
/* TODO: Audit below this */
export const saveStreamToDisk = writeStream;
export const saveFileToDisk = (path: string, contents: string) =>
export const fsWriteFile = (path: string, contents: string) =>
fs.writeFile(path, contents);
export const readTextFile = async (filePath: string) =>
fs.readFile(filePath, "utf-8");
/* TODO: Audit below this */
export const isFolder = async (dirPath: string) => {
if (!existsSync(dirPath)) return false;

View File

@@ -20,13 +20,12 @@ import {
import {
fsExists,
fsMkdirIfNeeded,
fsReadTextFile,
fsRename,
fsRm,
fsRmdir,
fsWriteFile,
isFolder,
readTextFile,
saveFileToDisk,
saveStreamToDisk,
} from "./fs";
import { logToDisk } from "./log";
import {
@@ -113,6 +112,26 @@ export const attachIPCHandlers = () => {
ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version));
// - FS
ipcMain.handle("fsExists", (_, path) => fsExists(path));
ipcMain.handle("fsRename", (_, oldPath: string, newPath: string) =>
fsRename(oldPath, newPath),
);
ipcMain.handle("fsMkdirIfNeeded", (_, dirPath) => fsMkdirIfNeeded(dirPath));
ipcMain.handle("fsRmdir", (_, path: string) => fsRmdir(path));
ipcMain.handle("fsRm", (_, path: string) => fsRm(path));
ipcMain.handle("fsReadTextFile", (_, path: string) => fsReadTextFile(path));
ipcMain.handle("fsWriteFile", (_, path: string, contents: string) =>
fsWriteFile(path, contents),
);
// - Conversion
ipcMain.handle("convertToJPEG", (_, fileData, filename) =>
@@ -164,34 +183,8 @@ export const attachIPCHandlers = () => {
ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog());
// - FS
ipcMain.handle("fsExists", (_, path) => fsExists(path));
ipcMain.handle("fsRename", (_, oldPath: string, newPath: string) =>
fsRename(oldPath, newPath),
);
ipcMain.handle("fsMkdirIfNeeded", (_, dirPath) => fsMkdirIfNeeded(dirPath));
ipcMain.handle("fsRmdir", (_, path: string) => fsRmdir(path));
ipcMain.handle("fsRm", (_, path: string) => fsRm(path));
// - FS Legacy
ipcMain.handle(
"saveStreamToDisk",
(_, path: string, fileStream: ReadableStream) =>
saveStreamToDisk(path, fileStream),
);
ipcMain.handle("saveFileToDisk", (_, path: string, contents: string) =>
saveFileToDisk(path, contents),
);
ipcMain.handle("readTextFile", (_, path: string) => readTextFile(path));
ipcMain.handle("isFolder", (_, dirPath: string) => isFolder(dirPath));
// - Upload

View File

@@ -2,7 +2,7 @@ import pathToFfmpeg from "ffmpeg-static";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { ElectronFile } from "../../types/ipc";
import { writeStream } from "../fs";
import { writeStream } from "../stream";
import log from "../log";
import { generateTempFilePath, getTempDirPath } from "../temp";
import { execAsync } from "../util";

View File

@@ -2,7 +2,7 @@ import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "path";
import { CustomErrors, ElectronFile } from "../../types/ipc";
import { writeStream } from "../fs";
import { writeStream } from "../stream";
import log from "../log";
import { isPlatform } from "../platform";
import { generateTempFilePath } from "../temp";

View File

@@ -11,7 +11,7 @@ import fs from "node:fs/promises";
import * as ort from "onnxruntime-node";
import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
import { CustomErrors } from "../../types/ipc";
import { writeStream } from "../fs";
import { writeStream } from "../stream";
import log from "../log";
import { generateTempFilePath } from "../temp";
import { deleteTempFile } from "./ffmpeg";

View File

@@ -15,7 +15,7 @@ import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "node:path";
import * as ort from "onnxruntime-node";
import { writeStream } from "../fs";
import { writeStream } from "../stream";
import log from "../log";
/**

116
desktop/src/main/stream.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* @file stream data to-from renderer using a custom protocol handler.
*/
import { protocol } from "electron/main";
import { createWriteStream, existsSync } from "node:fs";
import fs from "node:fs/promises";
import { Readable } from "node:stream";
import log from "./log";
/**
* Register a protocol handler that we use for streaming large files between the
* main process (node) and the renderer process (browser) layer.
*
* [Note: IPC streams]
*
* When running without node integration, there is no direct way to pass streams
* across IPC. And passing the entire contents of the file is not feasible for
* large video files because of the memory pressure the copying would entail.
*
* As an alternative, we register a custom protocol handler that can provided a
* bi-directional stream. The renderer can stream data to the node side by
* streaming the request. The node side can stream to the renderer side by
* streaming the response.
*
* See also: [Note: Transferring large amount of data over IPC]
*
* Depends on {@link registerPrivilegedSchemes}.
*/
export const registerStreamProtocol = () => {
protocol.handle("stream", async (request: Request) => {
const url = request.url;
const { host, pathname } = new URL(url);
// Convert e.g. "%20" to spaces.
const path = decodeURIComponent(pathname);
switch (host) {
/* stream://write/path/to/file */
/* host-pathname----- */
case "write":
try {
await writeStream(path, request.body);
return new Response("", { status: 200 });
} catch (e) {
log.error(`Failed to write stream for ${url}`, e);
return new Response(
`Failed to write stream: ${e.message}`,
{ status: 500 },
);
}
default:
return new Response("", { status: 404 });
}
});
};
/**
* Write a (web) ReadableStream to a file at the given {@link filePath}.
*
* The returned promise resolves when the write completes.
*
* @param filePath The local filesystem path where the file should be written.
* @param readableStream A [web
* ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
*/
export const writeStream = (filePath: string, readableStream: ReadableStream) =>
writeNodeStream(filePath, convertWebReadableStreamToNode(readableStream));
/**
* Convert a Web ReadableStream into a Node.js ReadableStream
*
* This can be used to, for example, write a ReadableStream obtained via
* `net.fetch` into a file using the Node.js `fs` APIs
*/
const convertWebReadableStreamToNode = (readableStream: ReadableStream) => {
const reader = readableStream.getReader();
const rs = new Readable();
rs._read = async () => {
try {
const result = await reader.read();
if (!result.done) {
rs.push(Buffer.from(result.value));
} else {
rs.push(null);
return;
}
} catch (e) {
rs.emit("error", e);
}
};
return rs;
};
const writeNodeStream = async (
filePath: string,
fileStream: NodeJS.ReadableStream,
) => {
const writeable = createWriteStream(filePath);
fileStream.on("error", (error) => {
writeable.destroy(error); // Close the writable stream with an error
});
fileStream.pipe(writeable);
await new Promise((resolve, reject) => {
writeable.on("finish", resolve);
writeable.on("error", async (e: unknown) => {
if (existsSync(filePath)) {
await fs.unlink(filePath);
}
reject(e);
});
});
};

View File

@@ -96,6 +96,8 @@ const skipAppUpdate = (version: string) => {
ipcRenderer.send("skipAppUpdate", version);
};
// - FS
const fsExists = (path: string): Promise<boolean> =>
ipcRenderer.invoke("fsExists", path);
@@ -110,6 +112,12 @@ const fsRmdir = (path: string): Promise<void> =>
const fsRm = (path: string): Promise<void> => ipcRenderer.invoke("fsRm", path);
const fsReadTextFile = (path: string): Promise<string> =>
ipcRenderer.invoke("fsReadTextFile", path);
const fsWriteFile = (path: string, contents: string): Promise<void> =>
ipcRenderer.invoke("fsWriteFile", path, contents);
// - AUDIT below this
// - Conversion
@@ -229,17 +237,6 @@ const updateWatchMappingIgnoredFiles = (
// - FS Legacy
const saveStreamToDisk = (
path: string,
fileStream: ReadableStream,
): Promise<void> => ipcRenderer.invoke("saveStreamToDisk", path, fileStream);
const saveFileToDisk = (path: string, contents: string): Promise<void> =>
ipcRenderer.invoke("saveFileToDisk", path, contents);
const readTextFile = (path: string): Promise<string> =>
ipcRenderer.invoke("readTextFile", path);
const isFolder = (dirPath: string): Promise<boolean> =>
ipcRenderer.invoke("isFolder", dirPath);
@@ -298,7 +295,8 @@ const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
// https://www.electronjs.org/docs/latest/api/context-bridge#methods
//
// The copy itself is relatively fast, but the problem with transfering large
// amounts of data is potentially running out of memory during the copy.
// amounts of data is potentially running out of memory during the copy. For an
// alternative, see [Note: IPC streams].
contextBridge.exposeInMainWorld("electron", {
// - General
appVersion,
@@ -316,6 +314,17 @@ contextBridge.exposeInMainWorld("electron", {
updateOnNextRestart,
skipAppUpdate,
// - FS
fs: {
exists: fsExists,
rename: fsRename,
mkdirIfNeeded: fsMkdirIfNeeded,
rmdir: fsRmdir,
rm: fsRm,
readTextFile: fsReadTextFile,
writeFile: fsWriteFile,
},
// - Conversion
convertToJPEG,
generateImageThumbnail,
@@ -341,20 +350,8 @@ contextBridge.exposeInMainWorld("electron", {
updateWatchMappingSyncedFiles,
updateWatchMappingIgnoredFiles,
// - FS
fs: {
exists: fsExists,
rename: fsRename,
mkdirIfNeeded: fsMkdirIfNeeded,
rmdir: fsRmdir,
rm: fsRm,
},
// - FS legacy
// TODO: Move these into fs + document + rename if needed
saveStreamToDisk,
saveFileToDisk,
readTextFile,
isFolder,
// - Upload

View File

@@ -22,6 +22,7 @@ linter:
- use_key_in_widget_constructors
- cancel_subscriptions
- avoid_empty_else
- exhaustive_cases
@@ -59,6 +60,7 @@ analyzer:
prefer_final_locals: warning
unnecessary_const: error
cancel_subscriptions: error
unrelated_type_equality_checks: error
unawaited_futures: warning # convert to warning after fixing existing issues

View File

@@ -3,12 +3,9 @@ PODS:
- Flutter
- battery_info (0.0.1):
- Flutter
- bonsoir_darwin (3.0.0):
- Flutter
- FlutterMacOS
- connectivity_plus (0.0.1):
- Flutter
- ReachabilitySwift
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- file_saver (0.0.1):
@@ -171,7 +168,6 @@ PODS:
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
- ReachabilitySwift (5.2.1)
- receive_sharing_intent (1.6.8):
- Flutter
- screen_brightness_ios (0.1.0):
@@ -231,8 +227,7 @@ PODS:
DEPENDENCIES:
- background_fetch (from `.symlinks/plugins/background_fetch/ios`)
- battery_info (from `.symlinks/plugins/battery_info/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_saver (from `.symlinks/plugins/file_saver/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
@@ -296,7 +291,6 @@ SPEC REPOS:
- onnxruntime-objc
- OrderedSet
- PromisesObjC
- ReachabilitySwift
- SDWebImage
- SDWebImageWebPCoder
- Sentry
@@ -309,10 +303,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/background_fetch/ios"
battery_info:
:path: ".symlinks/plugins/battery_info/ios"
bonsoir_darwin:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
:path: ".symlinks/plugins/connectivity_plus/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_saver:
@@ -409,8 +401,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
background_fetch: 2319bf7e18237b4b269430b7f14d177c0df09c5a
battery_info: 09f5c9ee65394f2291c8c6227bedff345b8a730c
bonsoir_darwin: 127bdc632fdc154ae2f277a4d5c86a6212bc75be
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: 797fd7297b7e1be954432743a0b3f90038e45a71
@@ -458,7 +449,6 @@ SPEC CHECKSUMS:
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
ReachabilitySwift: 5ae15e16814b5f9ef568963fb2c87aeb49158c66
receive_sharing_intent: 6837b01768e567fe8562182397bf43d63d8c6437
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb

View File

@@ -285,7 +285,6 @@
"${BUILT_PRODUCTS_DIR}/Mantle/Mantle.framework",
"${BUILT_PRODUCTS_DIR}/OrderedSet/OrderedSet.framework",
"${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework",
"${BUILT_PRODUCTS_DIR}/ReachabilitySwift/Reachability.framework",
"${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework",
"${BUILT_PRODUCTS_DIR}/SDWebImageWebPCoder/SDWebImageWebPCoder.framework",
"${BUILT_PRODUCTS_DIR}/Sentry/Sentry.framework",
@@ -293,7 +292,6 @@
"${BUILT_PRODUCTS_DIR}/Toast/Toast.framework",
"${BUILT_PRODUCTS_DIR}/background_fetch/background_fetch.framework",
"${BUILT_PRODUCTS_DIR}/battery_info/battery_info.framework",
"${BUILT_PRODUCTS_DIR}/bonsoir_darwin/bonsoir_darwin.framework",
"${BUILT_PRODUCTS_DIR}/connectivity_plus/connectivity_plus.framework",
"${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework",
"${BUILT_PRODUCTS_DIR}/file_saver/file_saver.framework",
@@ -369,7 +367,6 @@
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Mantle.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OrderedSet.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Reachability.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageWebPCoder.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework",
@@ -377,7 +374,6 @@
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Toast.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/background_fetch.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/battery_info.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/bonsoir_darwin.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/connectivity_plus.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_saver.framework",

View File

@@ -13,18 +13,13 @@ import 'package:media_extension/media_extension_action_types.dart';
import 'package:photos/ente_theme_data.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/l10n/l10n.dart";
import "package:photos/models/collection/collection_items.dart";
import 'package:photos/services/app_lifecycle_service.dart';
import "package:photos/services/collections_service.dart";
import "package:photos/services/favorites_service.dart";
import "package:photos/services/home_widget_service.dart";
import "package:photos/services/machine_learning/machine_learning_controller.dart";
import 'package:photos/services/sync_service.dart';
import 'package:photos/ui/tabs/home_widget.dart';
import "package:photos/ui/viewer/actions/file_viewer.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/utils/intent_util.dart";
import "package:photos/utils/navigation_util.dart";
class EnteApp extends StatefulWidget {
final Future<void> Function(String) runBackgroundTask;
@@ -66,39 +61,14 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
void didChangeDependencies() {
super.didChangeDependencies();
_checkForWidgetLaunch();
hw.HomeWidget.widgetClicked.listen(_launchedFromWidget);
}
void _checkForWidgetLaunch() {
hw.HomeWidget.initiallyLaunchedFromHomeWidget().then(_launchedFromWidget);
}
Future<void> _launchedFromWidget(Uri? uri) async {
if (uri == null) return;
final collectionID =
await FavoritesService.instance.getFavoriteCollectionID();
if (collectionID == null) {
return;
}
final collection = CollectionsService.instance.getCollectionByID(
collectionID,
hw.HomeWidget.initiallyLaunchedFromHomeWidget().then(
(uri) => HomeWidgetService.instance.onLaunchFromWidget(uri, context),
);
if (collection == null) {
return;
}
unawaited(HomeWidgetService.instance.initHomeWidget());
final thumbnail = await CollectionsService.instance.getCover(collection);
unawaited(
routeToPage(
context,
CollectionPage(
CollectionWithThumbnail(
collection,
thumbnail,
),
),
),
hw.HomeWidget.widgetClicked.listen(
(uri) => HomeWidgetService.instance.onLaunchFromWidget(uri, context),
);
}

View File

@@ -45,6 +45,9 @@ class FFDefault {
static const bool enablePasskey = false;
}
// this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part.
const multipartPartSize = 20 * 1024 * 1024;
const kDefaultProductionEndpoint = 'https://api.ente.io';
const int intMaxValue = 9223372036854775807;
@@ -71,11 +74,11 @@ const kSearchSectionLimit = 9;
const iOSGroupID = "group.io.ente.frame.SlideshowWidget";
const blackThumbnailBase64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB' +
'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ' +
'EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARC' +
'ACWASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF' +
'BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk' +
const blackThumbnailBase64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB'
'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ'
'EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARC'
'ACWASwDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUF'
'BAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk' +
'6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztL' +
'W2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAA' +
'AAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVY' +

View File

@@ -340,7 +340,7 @@ extension DeviceFiles on FilesDB {
int ownerID,
) async {
final db = await database;
const String rawQuery = '''
const String rawQuery = '''
SELECT ${FilesDB.columnLocalID}, ${FilesDB.columnUploadedFileID},
${FilesDB.columnFileSize}
FROM ${FilesDB.filesTable}

View File

@@ -85,13 +85,24 @@ class EnteFile {
static int parseFileCreationTime(String? fileTitle, AssetEntity asset) {
int creationTime = asset.createDateTime.microsecondsSinceEpoch;
final int modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
if (creationTime >= jan011981Time) {
// assuming that fileSystem is returning correct creationTime.
// During upload, this might get overridden with exif Creation time
// When the assetModifiedTime is less than creationTime, than just use
// that as creationTime. This is to handle cases where file might be
// copied to the fileSystem from somewhere else See #https://superuser.com/a/1091147
if (modificationTime >= jan011981Time &&
modificationTime < creationTime) {
_logger.info(
'LocalID: ${asset.id} modification time is less than creation time. Using modification time as creation time',
);
creationTime = modificationTime;
}
return creationTime;
} else {
if (asset.modifiedDateTime.microsecondsSinceEpoch >= jan011981Time) {
creationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
if (modificationTime >= jan011981Time) {
creationTime = modificationTime;
} else {
creationTime = DateTime.now().toUtc().microsecondsSinceEpoch;
}
@@ -106,7 +117,6 @@ class EnteFile {
// ignore
}
}
return creationTime;
}

View File

@@ -8,9 +8,14 @@ import "package:logging/logging.dart";
import "package:photos/core/configuration.dart";
import "package:photos/core/constants.dart";
import "package:photos/db/files_db.dart";
import "package:photos/models/collection/collection_items.dart";
import "package:photos/models/file/file_type.dart";
import "package:photos/services/collections_service.dart";
import "package:photos/services/favorites_service.dart";
import "package:photos/ui/viewer/file/detail_page.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/utils/file_util.dart";
import "package:photos/utils/navigation_util.dart";
import "package:photos/utils/preload_util.dart";
class HomeWidgetService {
@@ -171,4 +176,49 @@ class HomeWidgetService {
);
_logger.info(">>> SlideshowWidget cleared");
}
Future<void> onLaunchFromWidget(Uri? uri, BuildContext context) async {
if (uri == null) return;
final collectionID =
await FavoritesService.instance.getFavoriteCollectionID();
if (collectionID == null) {
return;
}
final collection = CollectionsService.instance.getCollectionByID(
collectionID,
);
if (collection == null) {
return;
}
final thumbnail = await CollectionsService.instance.getCover(collection);
final previousGeneratedId =
await hw.HomeWidget.getWidgetData<int>("home_widget_last_img");
final res = previousGeneratedId != null
? await FilesDB.instance.getFile(
previousGeneratedId,
)
: null;
routeToPage(
context,
CollectionPage(
CollectionWithThumbnail(
collection,
thumbnail,
),
),
).ignore();
if (res == null) return;
final page = DetailPage(
DetailPageConfiguration(List.unmodifiable([res]), null, 0, "collection"),
);
routeToPage(context, page, forceCustomPageRoute: true).ignore();
}
}

View File

@@ -4,7 +4,6 @@ import "dart:io";
import "package:connectivity_plus/connectivity_plus.dart";
import "package:logging/logging.dart";
import "package:photos/core/errors.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/events/event.dart";
import "package:photos/services/remote_assets_service.dart";
@@ -23,7 +22,7 @@ abstract class MLFramework {
MLFramework(this.shouldDownloadOverMobileData) {
Connectivity()
.onConnectivityChanged
.listen((ConnectivityResult result) async {
.listen((List<ConnectivityResult> result) async {
_logger.info("Connectivity changed to $result");
if (_state == InitializationState.waitingForNetwork &&
await _canDownload()) {
@@ -135,9 +134,11 @@ abstract class MLFramework {
}
Future<bool> _canDownload() async {
final connectivityResult = await (Connectivity().checkConnectivity());
return connectivityResult != ConnectivityResult.mobile ||
shouldDownloadOverMobileData;
final List<ConnectivityResult> connections =
await (Connectivity().checkConnectivity());
final bool isConnectedToMobile =
connections.contains(ConnectivityResult.mobile);
return !isConnectedToMobile || shouldDownloadOverMobileData;
}
}

View File

@@ -45,7 +45,9 @@ class SyncService {
sync();
});
Connectivity().onConnectivityChanged.listen((ConnectivityResult result) {
Connectivity()
.onConnectivityChanged
.listen((List<ConnectivityResult> result) {
_logger.info("Connectivity change detected " + result.toString());
if (Configuration.instance.hasConfiguredAccount()) {
sync();

View File

@@ -29,6 +29,7 @@ import "package:photos/models/metadata/file_magic.dart";
import 'package:photos/models/upload_url.dart';
import "package:photos/models/user_details.dart";
import 'package:photos/services/collections_service.dart';
import "package:photos/services/feature_flag_service.dart";
import "package:photos/services/file_magic_service.dart";
import 'package:photos/services/local_sync_service.dart';
import 'package:photos/services/sync_service.dart';
@@ -37,6 +38,7 @@ import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/file_download_util.dart';
import 'package:photos/utils/file_uploader_util.dart';
import "package:photos/utils/file_util.dart";
import "package:photos/utils/multipart_upload_util.dart";
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tuple/tuple.dart';
import "package:uuid/uuid.dart";
@@ -353,9 +355,10 @@ class FileUploader {
if (isForceUpload) {
return;
}
final connectivityResult = await (Connectivity().checkConnectivity());
final List<ConnectivityResult> connections =
await (Connectivity().checkConnectivity());
bool canUploadUnderCurrentNetworkConditions = true;
if (connectivityResult == ConnectivityResult.mobile) {
if (connections.any((element) => element == ConnectivityResult.mobile)) {
canUploadUnderCurrentNetworkConditions =
Configuration.instance.shouldBackupOverMobileData();
}
@@ -492,8 +495,23 @@ class FileUploader {
final String thumbnailObjectKey =
await _putFile(thumbnailUploadURL, encryptedThumbnailFile);
final fileUploadURL = await _getUploadURL();
final String fileObjectKey = await _putFile(fileUploadURL, encryptedFile);
// Calculate the number of parts for the file. Multiple part upload
// is only enabled for internal users and debug builds till it's battle tested.
final count = FeatureFlagService.instance.isInternalUserOrDebugBuild()
? await calculatePartCount(
await encryptedFile.length(),
)
: 1;
late String fileObjectKey;
if (count <= 1) {
final fileUploadURL = await _getUploadURL();
fileObjectKey = await _putFile(fileUploadURL, encryptedFile);
} else {
final fileUploadURLs = await getMultipartUploadURLs(count);
fileObjectKey = await putMultipartFile(fileUploadURLs, encryptedFile);
}
final metadata = await file.getMetadataForUpload(mediaUploadData);
final encryptedMetadataResult = await CryptoUtil.encryptChaCha(

View File

@@ -0,0 +1,157 @@
// ignore_for_file: implementation_imports
import "dart:io";
import "package:dio/dio.dart";
import "package:logging/logging.dart";
import "package:photos/core/constants.dart";
import "package:photos/core/network/network.dart";
import "package:photos/services/feature_flag_service.dart";
import "package:photos/utils/xml_parser_util.dart";
final _enteDio = NetworkClient.instance.enteDio;
final _dio = NetworkClient.instance.getDio();
class PartETag extends XmlParsableObject {
final int partNumber;
final String eTag;
PartETag(this.partNumber, this.eTag);
@override
String get elementName => "Part";
@override
Map<String, dynamic> toMap() {
return {
"PartNumber": partNumber,
"ETag": eTag,
};
}
}
class MultipartUploadURLs {
final String objectKey;
final List<String> partsURLs;
final String completeURL;
MultipartUploadURLs({
required this.objectKey,
required this.partsURLs,
required this.completeURL,
});
factory MultipartUploadURLs.fromMap(Map<String, dynamic> map) {
return MultipartUploadURLs(
objectKey: map["urls"]["objectKey"],
partsURLs: (map["urls"]["partURLs"] as List).cast<String>(),
completeURL: map["urls"]["completeURL"],
);
}
}
Future<int> calculatePartCount(int fileSize) async {
final partCount = (fileSize / multipartPartSize).ceil();
return partCount;
}
Future<MultipartUploadURLs> getMultipartUploadURLs(int count) async {
try {
assert(
FeatureFlagService.instance.isInternalUserOrDebugBuild(),
"Multipart upload should not be enabled for external users.",
);
final response = await _enteDio.get(
"/files/multipart-upload-urls",
queryParameters: {
"count": count,
},
);
return MultipartUploadURLs.fromMap(response.data);
} on Exception catch (e) {
Logger("MultipartUploadURL").severe(e);
rethrow;
}
}
Future<String> putMultipartFile(
MultipartUploadURLs urls,
File encryptedFile,
) async {
// upload individual parts and get their etags
final etags = await uploadParts(urls.partsURLs, encryptedFile);
// complete the multipart upload
await completeMultipartUpload(etags, urls.completeURL);
return urls.objectKey;
}
Future<Map<int, String>> uploadParts(
List<String> partsURLs,
File encryptedFile,
) async {
final partsLength = partsURLs.length;
final etags = <int, String>{};
for (int i = 0; i < partsLength; i++) {
final partURL = partsURLs[i];
final isLastPart = i == partsLength - 1;
final fileSize = isLastPart
? encryptedFile.lengthSync() % multipartPartSize
: multipartPartSize;
final response = await _dio.put(
partURL,
data: encryptedFile.openRead(
i * multipartPartSize,
isLastPart ? null : multipartPartSize,
),
options: Options(
headers: {
Headers.contentLengthHeader: fileSize,
},
),
);
final eTag = response.headers.value("etag");
if (eTag?.isEmpty ?? true) {
throw Exception('ETAG_MISSING');
}
etags[i] = eTag!;
}
return etags;
}
Future<void> completeMultipartUpload(
Map<int, String> partEtags,
String completeURL,
) async {
final body = convertJs2Xml({
'CompleteMultipartUpload': partEtags.entries
.map(
(e) => PartETag(
e.key + 1,
e.value,
),
)
.toList(),
}).replaceAll('"', '').replaceAll('&quot;', '');
try {
await _dio.post(
completeURL,
data: body,
options: Options(
contentType: "text/xml",
),
);
} catch (e) {
Logger("MultipartUpload").severe(e);
rethrow;
}
}

View File

@@ -0,0 +1,41 @@
// ignore_for_file: implementation_imports
import "package:xml/xml.dart";
// used for classes that can be converted to xml
abstract class XmlParsableObject {
Map<String, dynamic> toMap();
String get elementName;
}
// for converting the response to xml
String convertJs2Xml(Map<String, dynamic> json) {
final builder = XmlBuilder();
buildXml(builder, json);
return builder.buildDocument().toXmlString(
pretty: true,
indent: ' ',
);
}
// for building the xml node tree recursively
void buildXml(XmlBuilder builder, dynamic node) {
if (node is Map<String, dynamic>) {
node.forEach((key, value) {
builder.element(key, nest: () => buildXml(builder, value));
});
} else if (node is List<dynamic>) {
for (var item in node) {
buildXml(builder, item);
}
} else if (node is XmlParsableObject) {
builder.element(
node.elementName,
nest: () {
buildXml(builder, node.toMap());
},
);
} else {
builder.text(node.toString());
}
}

View File

@@ -113,38 +113,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.6"
bonsoir:
dependency: transitive
description:
name: bonsoir
sha256: "800d77c0581fff06cc43ef2b7723dfe5ee9b899ab0fdf80fb1c7b8829a5deb5c"
url: "https://pub.dev"
source: hosted
version: "3.0.0+1"
bonsoir_android:
dependency: transitive
description:
name: bonsoir_android
sha256: "7207c36fd7e0f3c7c2d8cf353f02bd640d96e2387d575837f8ac051c9cbf4aa7"
url: "https://pub.dev"
source: hosted
version: "3.0.0+1"
bonsoir_darwin:
dependency: transitive
description:
name: bonsoir_darwin
sha256: "7211042c85da2d6efa80c0976bbd9568f2b63624097779847548ed4530675ade"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
bonsoir_platform_interface:
dependency: transitive
description:
name: bonsoir_platform_interface
sha256: "64d57cd52bd477b4891e9b9d419e6408da171ed9e0efc8aa716e7e343d5d93ad"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
boolean_selector:
dependency: transitive
description:
@@ -241,14 +209,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
cast:
dependency: "direct main"
description:
name: cast
sha256: b70f6be547a53481dffec93ad3cc4974fae5ed707f0b677d4a50c329d7299b98
url: "https://pub.dev"
source: hosted
version: "2.0.0"
characters:
dependency: transitive
description:
@@ -326,18 +286,18 @@ packages:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b"
sha256: ebe15d94de9dd7c31dc2ac54e42780acdf3384b1497c69290c9f3c5b0279fc57
url: "https://pub.dev"
source: hosted
version: "4.0.2"
version: "6.0.2"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a
sha256: b6a56efe1e6675be240de39107281d4034b64ac23438026355b4234042a35adb
url: "https://pub.dev"
source: hosted
version: "1.2.4"
version: "2.0.0"
convert:
dependency: transitive
description:
@@ -1769,14 +1729,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
provider:
dependency: "direct main"
description:
@@ -2584,7 +2536,7 @@ packages:
source: hosted
version: "1.0.4"
xml:
dependency: transitive
dependency: "direct main"
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226

View File

@@ -12,7 +12,7 @@ description: ente photos application
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.8.81+601
version: 0.8.82+602
publish_to: none
environment:
@@ -27,7 +27,6 @@ dependencies:
battery_info: ^1.1.1
bip39: ^1.0.6
cached_network_image: ^3.0.0
cast: ^2.0.0
chewie:
git:
url: https://github.com/ente-io/chewie.git
@@ -37,11 +36,7 @@ dependencies:
collection: # dart
computer:
git: "https://github.com/ente-io/computer.git"
connectivity_plus:
git:
url: https://github.com/ente-io/plus_plugins.git
ref: check_mobile_first
path: packages/connectivity_plus/connectivity_plus/
connectivity_plus: ^6.0.2
cross_file: ^0.3.3
crypto: ^3.0.2
cupertino_icons: ^1.0.0
@@ -169,9 +164,9 @@ dependencies:
wallpaper_manager_flutter: ^0.0.2
wechat_assets_picker: ^8.6.3
widgets_to_image: ^0.0.2
xml: ^6.3.0
dependency_overrides:
connectivity_plus: ^4.0.0
# Remove this after removing dependency from flutter_sodium.
# Newer flutter packages depends on ffi > 2.0.0 while flutter_sodium depends on ffi < 2.0.0
ffi: 2.1.0

View File

@@ -73,8 +73,11 @@ http:
# Specify the base endpoints for various apps
apps:
public-albums: "https://albums.ente.io"
# Default is https://albums.ente.io
#
# If you're running a self hosted instance and wish to serve public links,
# set this to the URL where your albums web app is running.
public-albums:
# Database connection parameters
db:

View File

@@ -23,7 +23,7 @@ type PublicCollectionRepository struct {
// NewPublicCollectionRepository ..
func NewPublicCollectionRepository(db *sql.DB, albumHost string) *PublicCollectionRepository {
if albumHost == "" {
panic("albumHost can not be empty")
albumHost = "https://albums.ente.io"
}
return &PublicCollectionRepository{
DB: db,

View File

@@ -73,8 +73,9 @@ stripe:
key: stripe_dev_key
webhook-secret: stripe_dev_webhook_secret
whitelisted-redirect-urls:
- "http://localhost:3000/gallery"
- "http://192.168.1.2:3001/frameRedirect"
- http://localhost:3000/gallery
- http://localhost:3001/desktop-redirect
- http://192.168.1.2:3001/frameRedirect
path:
success: ?status=success&session_id={CHECKOUT_SESSION_ID}
cancel: ?status=fail&reason=canceled

View File

@@ -9,6 +9,14 @@ import { loadStripe } from "@stripe/stripe-js";
* redirect to the client or to some fallback URL.
*/
export const parseAndHandleRequest = async () => {
// See: [Note: Intercept payments redirection to desktop app]
if (window.location.pathname == "/desktop-redirect") {
const desktopRedirectURL = new URL("ente://app/gallery");
desktopRedirectURL.search = new URL(window.location.href).search;
window.location.href = desktopRedirectURL.href;
return;
}
try {
const urlParams = new URLSearchParams(window.location.search);
const productID = urlParams.get("productID");
@@ -291,6 +299,8 @@ const redirectToApp = (
status: RedirectStatus,
reason?: FailureReason,
) => {
// [Note: Intercept payments redirection to desktop app]
//
// The desktop app passes "<our-origin>/desktop-redirect" as `redirectURL`.
// This is just a placeholder, we want to intercept this and instead
// redirect to the ente:// scheme protocol handler that is internally being

View File

@@ -57,7 +57,8 @@ export default function SearchInput(props: Iprops) {
const appContext = useContext(AppContext);
const handleChange = (value: SearchOption) => {
setValue(value);
setQuery(value.label);
setQuery(value?.label);
blur();
};
const handleInputChange = (value: string, actionMeta: InputActionMeta) => {

View File

@@ -7,7 +7,6 @@ import { CustomError } from "@ente/shared/error";
import { Events, eventBus } from "@ente/shared/events";
import { Remote } from "comlink";
import { FILE_TYPE } from "constants/file";
import isElectron from "is-electron";
import { EnteFile } from "types/file";
import {
generateStreamFromArrayBuffer,
@@ -89,11 +88,12 @@ class DownloadManagerImpl {
e,
);
}
try {
if (isElectron()) this.fileCache = await openCache("files");
} catch (e) {
log.error("Failed to open file cache, will continue without it", e);
}
// TODO (MR): Revisit full file caching cf disk space usage
// try {
// if (isElectron()) this.fileCache = await openCache("files");
// } catch (e) {
// log.error("Failed to open file cache, will continue without it", e);
// }
this.cryptoWorker = await ComlinkCryptoWorker.getInstance();
this.ready = true;
eventBus.on(Events.LOGOUT, this.logoutHandler.bind(this), this);

View File

@@ -34,6 +34,7 @@ import {
mergeMetadata,
} from "utils/file";
import { safeDirectoryName, safeFileName } from "utils/native-fs";
import { writeStream } from "utils/native-stream";
import { getAllLocalCollections } from "../collectionService";
import downloadManager from "../download";
import { getAllLocalFiles } from "../fileService";
@@ -884,7 +885,7 @@ class ExportService {
try {
const exportRecord = await this.getExportRecord(folder);
const newRecord: ExportRecord = { ...exportRecord, ...newData };
await ensureElectron().saveFileToDisk(
await ensureElectron().fs.writeFile(
`${folder}/${exportRecordFileName}`,
JSON.stringify(newRecord, null, 2),
);
@@ -907,8 +908,7 @@ class ExportService {
if (!(await fs.exists(exportRecordJSONPath))) {
return this.createEmptyExportRecord(exportRecordJSONPath);
}
const recordFile =
await electron.readTextFile(exportRecordJSONPath);
const recordFile = await fs.readTextFile(exportRecordJSONPath);
try {
return JSON.parse(recordFile);
} catch (e) {
@@ -993,7 +993,7 @@ class ExportService {
fileExportName,
file,
);
await electron.saveStreamToDisk(
await writeStream(
`${collectionExportPath}/${fileExportName}`,
updatedFileStream,
);
@@ -1044,7 +1044,7 @@ class ExportService {
imageExportName,
file,
);
await electron.saveStreamToDisk(
await writeStream(
`${collectionExportPath}/${imageExportName}`,
imageStream,
);
@@ -1056,7 +1056,7 @@ class ExportService {
file,
);
try {
await electron.saveStreamToDisk(
await writeStream(
`${collectionExportPath}/${videoExportName}`,
videoStream,
);
@@ -1077,7 +1077,7 @@ class ExportService {
fileExportName: string,
file: EnteFile,
) {
await ensureElectron().saveFileToDisk(
await ensureElectron().fs.writeFile(
getFileMetadataExportPath(collectionExportPath, fileExportName),
getGoogleLikeMetadataFile(fileExportName, file),
);
@@ -1106,7 +1106,7 @@ class ExportService {
private createEmptyExportRecord = async (exportRecordJSONPath: string) => {
const exportRecord: ExportRecord = NULL_EXPORT_RECORD;
await ensureElectron().saveFileToDisk(
await ensureElectron().fs.writeFile(
exportRecordJSONPath,
JSON.stringify(exportRecord, null, 2),
);

View File

@@ -53,6 +53,7 @@ import { VISIBILITY_STATE } from "types/magicMetadata";
import { FileTypeInfo } from "types/upload";
import { isArchivedFile, updateMagicMetadata } from "utils/magicMetadata";
import { safeFileName } from "utils/native-fs";
import { writeStream } from "utils/native-stream";
const WAIT_TIME_IMAGE_CONVERSION = 30 * 1000;
@@ -798,55 +799,47 @@ async function downloadFileDesktop(
electron: Electron,
fileReader: FileReader,
file: EnteFile,
downloadPath: string,
downloadDir: string,
) {
const fileStream = (await DownloadManager.getFile(
const fs = electron.fs;
const stream = (await DownloadManager.getFile(
file,
)) as ReadableStream<Uint8Array>;
const updatedFileStream = await getUpdatedEXIFFileForDownload(
const updatedStream = await getUpdatedEXIFFileForDownload(
fileReader,
file,
fileStream,
stream,
);
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const fileBlob = await new Response(updatedFileStream).blob();
const fileBlob = await new Response(updatedStream).blob();
const livePhoto = await decodeLivePhoto(file, fileBlob);
const imageExportName = await safeFileName(
downloadPath,
downloadDir,
livePhoto.imageNameTitle,
electron.fs.exists,
fs.exists,
);
const imageStream = generateStreamFromArrayBuffer(livePhoto.image);
await electron.saveStreamToDisk(
`${downloadPath}/${imageExportName}`,
imageStream,
);
await writeStream(`${downloadDir}/${imageExportName}`, imageStream);
try {
const videoExportName = await safeFileName(
downloadPath,
downloadDir,
livePhoto.videoNameTitle,
electron.fs.exists,
fs.exists,
);
const videoStream = generateStreamFromArrayBuffer(livePhoto.video);
await electron.saveStreamToDisk(
`${downloadPath}/${videoExportName}`,
videoStream,
);
await writeStream(`${downloadDir}/${videoExportName}`, videoStream);
} catch (e) {
await electron.fs.rm(`${downloadPath}/${imageExportName}`);
await fs.rm(`${downloadDir}/${imageExportName}`);
throw e;
}
} else {
const fileExportName = await safeFileName(
downloadPath,
downloadDir,
file.metadata.title,
electron.fs.exists,
);
await electron.saveStreamToDisk(
`${downloadPath}/${fileExportName}`,
updatedFileStream,
fs.exists,
);
await writeStream(`${downloadDir}/${fileExportName}`, updatedStream);
}
}

View File

@@ -0,0 +1,39 @@
/**
* @file Streaming IPC communication with the Node.js layer of our desktop app.
*
* NOTE: These functions only work when we're running in our desktop app.
*/
/**
* Write the given stream to a file on the local machine.
*
* **This only works when we're running in our desktop app**. It uses the
* "stream://" protocol handler exposed by our custom code in the Node.js layer.
* See: [Note: IPC streams].
*
* @param path The path on the local machine where to write the file to.
* @param stream The stream which should be written into the file.
* */
export const writeStream = async (path: string, stream: ReadableStream) => {
// The duplex parameter needs to be set to 'half' when streaming requests.
//
// Currently browsers, and specifically in our case, since this code runs
// only within our desktop (Electron) app, Chromium, don't support 'full'
// duplex mode (i.e. streaming both the request and the response).
// https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
//
// In another twist, the TypeScript libdom.d.ts does not include the
// "duplex" parameter, so we need to cast to get TypeScript to let this code
// through. e.g. see https://github.com/node-fetch/node-fetch/issues/1769
const req = new Request(`stream://write${path}`, {
// GET can't have a body
method: "POST",
body: stream,
duplex: "half",
} as unknown as RequestInit);
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to write stream to ${path}: HTTP ${res.status}`,
);
};

View File

@@ -2,8 +2,8 @@
"HERO_SLIDE_1_TITLE": "<div>Private Sicherungen</div><div>für deine Erinnerungen</div>",
"HERO_SLIDE_1": "Standardmäßig Ende-zu-Ende verschlüsselt",
"HERO_SLIDE_2_TITLE": "<div>Sicher gespeichert</div><div>in einem Luftschutzbunker</div>",
"HERO_SLIDE_2": "Entwickelt um zu bewahren",
"HERO_SLIDE_3_TITLE": "<div>Verfügbar</div><div> überall</div>",
"HERO_SLIDE_2": "Entwickelt um zu überleben",
"HERO_SLIDE_3_TITLE": "<div>Überall</div><div> verfügbar</div>",
"HERO_SLIDE_3": "Android, iOS, Web, Desktop",
"LOGIN": "Anmelden",
"SIGN_UP": "Registrieren",
@@ -168,7 +168,7 @@
"UPDATE_PAYMENT_METHOD": "Zahlungsmethode aktualisieren",
"MONTHLY": "Monatlich",
"YEARLY": "Jährlich",
"update_subscription_title": "",
"update_subscription_title": "Tarifänderung bestätigen",
"UPDATE_SUBSCRIPTION_MESSAGE": "Sind Sie sicher, dass Sie Ihren Tarif ändern möchten?",
"UPDATE_SUBSCRIPTION": "Plan ändern",
"CANCEL_SUBSCRIPTION": "Abonnement kündigen",
@@ -278,15 +278,15 @@
"LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "Ihr Browser oder ein Addon blockiert Ente vor der Speicherung von Daten im lokalen Speicher. Bitte versuchen Sie, den Browser-Modus zu wechseln und die Seite neu zu laden.",
"SEND_OTT": "OTP senden",
"EMAIl_ALREADY_OWNED": "Diese E-Mail wird bereits verwendet",
"ETAGS_BLOCKED": "",
"LIVE_PHOTOS_DETECTED": "",
"ETAGS_BLOCKED": "<p>Die folgenden Dateien konnten aufgrund deiner Browser-Konfiguration nicht hochgeladen werden.</p><p>Bitte deaktiviere alle Add-ons, die Ente daran hindern könnten, <code>eTags</code> zum Hochladen großer Dateien zu verwenden oder verwende unsere <a>Desktop-App</a> für ein zuverlässigeres Import-Erlebnis.</p>",
"LIVE_PHOTOS_DETECTED": "Die Foto- und Videodateien deiner Live-Fotos wurden in einer einzigen Datei zusammengeführt",
"RETRY_FAILED": "Fehlgeschlagene Uploads erneut probieren",
"FAILED_UPLOADS": "Fehlgeschlagene Uploads ",
"SKIPPED_FILES": "Ignorierte Uploads",
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "Das Vorschaubild konnte nicht erzeugt werden",
"UNSUPPORTED_FILES": "Nicht unterstützte Dateien",
"SUCCESSFUL_UPLOADS": "Erfolgreiche Uploads",
"SKIPPED_INFO": "",
"SKIPPED_INFO": "Diese wurden übersprungen, da es Dateien mit gleichen Namen im selben Album gibt",
"UNSUPPORTED_INFO": "Ente unterstützt diese Dateiformate noch nicht",
"BLOCKED_UPLOADS": "Blockierte Uploads",
"INPROGRESS_METADATA_EXTRACTION": "In Bearbeitung",
@@ -315,20 +315,20 @@
"REMOVE_FROM_COLLECTION": "Aus Album entfernen",
"TRASH": "Papierkorb",
"MOVE_TO_TRASH": "In Papierkorb verschieben",
"TRASH_FILES_MESSAGE": "",
"TRASH_FILE_MESSAGE": "",
"TRASH_FILES_MESSAGE": "Ausgewählte Dateien werden aus allen Alben entfernt und in den Papierkorb verschoben.",
"TRASH_FILE_MESSAGE": "Die Datei wird aus allen Alben entfernt und in den Papierkorb verschoben.",
"DELETE_PERMANENTLY": "Dauerhaft löschen",
"RESTORE": "Wiederherstellen",
"RESTORE_TO_COLLECTION": "In Album wiederherstellen",
"EMPTY_TRASH": "Papierkorb leeren",
"EMPTY_TRASH_TITLE": "Papierkorb leeren?",
"EMPTY_TRASH_MESSAGE": "",
"EMPTY_TRASH_MESSAGE": "Diese Dateien werden dauerhaft aus Ihrem Ente-Konto gelöscht.",
"LEAVE_SHARED_ALBUM": "Ja, verlassen",
"LEAVE_ALBUM": "Album verlassen",
"LEAVE_SHARED_ALBUM_TITLE": "Geteiltes Album verlassen?",
"LEAVE_SHARED_ALBUM_MESSAGE": "",
"LEAVE_SHARED_ALBUM_MESSAGE": "Du wirst das Album verlassen und es wird nicht mehr für dich sichtbar sein.",
"NOT_FILE_OWNER": "Dateien in einem freigegebenen Album können nicht gelöscht werden",
"CONFIRM_SELF_REMOVE_MESSAGE": "",
"CONFIRM_SELF_REMOVE_MESSAGE": "Ausgewählte Elemente werden aus diesem Album entfernt. Elemente, die sich nur in diesem Album befinden, werden nach Unkategorisiert verschoben.",
"CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "Einige der Elemente, die du entfernst, wurden von anderen Nutzern hinzugefügt und du wirst den Zugriff auf sie verlieren.",
"SORT_BY_CREATION_TIME_ASCENDING": "Ältestem",
"SORT_BY_UPDATION_TIME_DESCENDING": "Zuletzt aktualisiert",
@@ -337,8 +337,8 @@
"FIX_CREATION_TIME_IN_PROGRESS": "Zeit wird repariert",
"CREATION_TIME_UPDATED": "Datei-Zeit aktualisiert",
"UPDATE_CREATION_TIME_NOT_STARTED": "Wählen Sie die Option, die Sie verwenden möchten",
"UPDATE_CREATION_TIME_COMPLETED": "",
"UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "",
"UPDATE_CREATION_TIME_COMPLETED": "Alle Dateien erfolgreich aktualisiert",
"UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "Aktualisierung der Dateizeit für einige Dateien fehlgeschlagen, bitte versuche es erneut",
"CAPTION_CHARACTER_LIMIT": "Maximal 5000 Zeichen",
"DATE_TIME_ORIGINAL": "",
"DATE_TIME_DIGITIZED": "",
@@ -358,10 +358,10 @@
"participants_one": "1 Teilnehmer",
"participants_other": "{{count, number}} Teilnehmer",
"ADD_VIEWERS": "Betrachter hinzufügen",
"CHANGE_PERMISSIONS_TO_VIEWER": "",
"CHANGE_PERMISSIONS_TO_COLLABORATOR": "",
"CHANGE_PERMISSIONS_TO_VIEWER": "<p>{{selectedEmail}} wird nicht in der Lage sein, weitere Fotos zum Album</p> <p>hinzuzufügen. {{selectedEmail}} wird weiterhin die eigenen Fotos aus dem Album entfernen können</p>",
"CHANGE_PERMISSIONS_TO_COLLABORATOR": "{{selectedEmail}} wird Fotos zum Album hinzufügen können",
"CONVERT_TO_VIEWER": "Ja, zu \"Beobachter\" ändern",
"CONVERT_TO_COLLABORATOR": "",
"CONVERT_TO_COLLABORATOR": "Ja, in Kollaborateur umwandeln",
"CHANGE_PERMISSION": "Berechtigung ändern?",
"REMOVE_PARTICIPANT": "Entfernen?",
"CONFIRM_REMOVE": "Ja, entfernen",
@@ -408,11 +408,11 @@
"STOP_ALL_UPLOADS_MESSAGE": "",
"STOP_UPLOADS_HEADER": "Hochladen stoppen?",
"YES_STOP_UPLOADS": "Ja, Hochladen stoppen",
"STOP_DOWNLOADS_HEADER": "",
"YES_STOP_DOWNLOADS": "",
"STOP_ALL_DOWNLOADS_MESSAGE": "",
"STOP_DOWNLOADS_HEADER": "Downloads anhalten?",
"YES_STOP_DOWNLOADS": "Ja, Downloads anhalten",
"STOP_ALL_DOWNLOADS_MESSAGE": "Bist du dir sicher, dass du alle laufenden Downloads anhalten möchtest?",
"albums_one": "1 Album",
"albums_other": "",
"albums_other": "{{count, number}} Alben",
"ALL_ALBUMS": "Alle Alben",
"ALBUMS": "Alben",
"ALL_HIDDEN_ALBUMS": "",
@@ -424,7 +424,7 @@
"COPIED": "Kopiert",
"WATCH_FOLDERS": "",
"UPGRADE_NOW": "Jetzt upgraden",
"RENEW_NOW": "",
"RENEW_NOW": "Jetzt erneuern",
"STORAGE": "Speicher",
"USED": "verwendet",
"YOU": "Sie",
@@ -432,10 +432,10 @@
"FREE": "frei",
"OF": "von",
"WATCHED_FOLDERS": "",
"NO_FOLDERS_ADDED": "",
"NO_FOLDERS_ADDED": "Noch keine Ordner hinzugefügt!",
"FOLDERS_AUTOMATICALLY_MONITORED": "",
"UPLOAD_NEW_FILES_TO_ENTE": "",
"REMOVE_DELETED_FILES_FROM_ENTE": "",
"REMOVE_DELETED_FILES_FROM_ENTE": "Gelöschte Dateien aus Ente entfernen",
"ADD_FOLDER": "Ordner hinzufügen",
"STOP_WATCHING": "",
"STOP_WATCHING_FOLDER": "",
@@ -455,48 +455,48 @@
"CURRENT_USAGE": "Aktuelle Nutzung ist <strong>{{usage}}</strong>",
"WEAK_DEVICE": "",
"DRAG_AND_DROP_HINT": "",
"CONFIRM_ACCOUNT_DELETION_MESSAGE": "",
"CONFIRM_ACCOUNT_DELETION_MESSAGE": "Ihre hochgeladenen Daten werden zur Löschung vorgemerkt, und Ihr Konto wird endgültig gelöscht.<br/><br/>Dieser Vorgang kann nicht rückgängig gemacht werden.",
"AUTHENTICATE": "Authentifizieren",
"UPLOADED_TO_SINGLE_COLLECTION": "",
"UPLOADED_TO_SEPARATE_COLLECTIONS": "",
"NEVERMIND": "Egal",
"UPDATE_AVAILABLE": "Neue Version verfügbar",
"UPDATE_INSTALLABLE_MESSAGE": "",
"UPDATE_INSTALLABLE_MESSAGE": "Eine neue Version von Ente ist für die Installation bereit.",
"INSTALL_NOW": "Jetzt installieren",
"INSTALL_ON_NEXT_LAUNCH": "Beim nächsten Start installieren",
"UPDATE_AVAILABLE_MESSAGE": "",
"DOWNLOAD_AND_INSTALL": "",
"UPDATE_AVAILABLE_MESSAGE": "Eine neue Version von Ente wurde veröffentlicht, aber sie kann nicht automatisch heruntergeladen und installiert werden.",
"DOWNLOAD_AND_INSTALL": "Herunterladen und installieren",
"IGNORE_THIS_VERSION": "Diese Version ignorieren",
"TODAY": "Heute",
"YESTERDAY": "Gestern",
"NAME_PLACEHOLDER": "Name...",
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "",
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "Alben können nicht aus Datei/Ordnermix erstellt werden",
"ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "",
"CHOSE_THEME": "",
"ML_SEARCH": "",
"CHOSE_THEME": "Design auswählen",
"ML_SEARCH": "Gesichtserkennung",
"ENABLE_ML_SEARCH_DESCRIPTION": "",
"ML_MORE_DETAILS": "",
"ENABLE_FACE_SEARCH": "",
"ENABLE_FACE_SEARCH_TITLE": "",
"ENABLE_FACE_SEARCH_DESCRIPTION": "",
"ML_MORE_DETAILS": "Weitere Details",
"ENABLE_FACE_SEARCH": "Gesichtserkennung aktivieren",
"ENABLE_FACE_SEARCH_TITLE": "Gesichtserkennung aktivieren?",
"ENABLE_FACE_SEARCH_DESCRIPTION": "<p>Wenn du die Gesichtserkennung aktivierst, wird Ente Gesichtsgeometrie aus deinen Fotos extrahieren. Dies wird auf deinem Gerät geschehen, und alle erzeugten biometrischen Daten werden Ende-zu-verschlüsselt.<p/><p><a>Bitte klicke hier für weitere Informationen über diese Funktion in unserer Datenschutzerklärung</a></p>",
"DISABLE_BETA": "Beta deaktivieren",
"DISABLE_FACE_SEARCH": "",
"DISABLE_FACE_SEARCH_TITLE": "",
"DISABLE_FACE_SEARCH": "Gesichtserkennung deaktivieren",
"DISABLE_FACE_SEARCH_TITLE": "Gesichtserkennung deaktivieren?",
"DISABLE_FACE_SEARCH_DESCRIPTION": "",
"ADVANCED": "Erweitert",
"FACE_SEARCH_CONFIRMATION": "",
"LABS": "",
"FACE_SEARCH_CONFIRMATION": "Ich verstehe und möchte Ente erlauben, Gesichtsgeometrie zu verarbeiten",
"LABS": "Experimente",
"YOURS": "",
"PASSPHRASE_STRENGTH_WEAK": "Passwortstärke: Schwach",
"PASSPHRASE_STRENGTH_MODERATE": "",
"PASSPHRASE_STRENGTH_MODERATE": "Passwortstärke: Moderat",
"PASSPHRASE_STRENGTH_STRONG": "Passwortstärke: Stark",
"PREFERENCES": "Einstellungen",
"LANGUAGE": "Sprache",
"EXPORT_DIRECTORY_DOES_NOT_EXIST": "",
"EXPORT_DIRECTORY_DOES_NOT_EXIST": "Ungültiges Exportverzeichnis",
"EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "",
"SUBSCRIPTION_VERIFICATION_ERROR": "",
"SUBSCRIPTION_VERIFICATION_ERROR": "Verifizierung des Abonnements fehlgeschlagen",
"STORAGE_UNITS": {
"B": "",
"B": "B",
"KB": "KB",
"MB": "MB",
"GB": "GB",
@@ -520,8 +520,8 @@
"PUBLIC_COLLECT_SUBTEXT": "",
"STOP_EXPORT": "Stop",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"MIGRATING_EXPORT": "Vorbereiten...",
"RENAMING_COLLECTION_FOLDERS": "Albumordner umbenennen...",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
@@ -536,12 +536,12 @@
"NOT_LISTED": ""
},
"DELETE_ACCOUNT_FEEDBACK_LABEL": "",
"DELETE_ACCOUNT_FEEDBACK_PLACEHOLDER": "",
"CONFIRM_DELETE_ACCOUNT_CHECKBOX_LABEL": "",
"DELETE_ACCOUNT_FEEDBACK_PLACEHOLDER": "Feedback",
"CONFIRM_DELETE_ACCOUNT_CHECKBOX_LABEL": "Ja, ich möchte dieses Konto und alle enthaltenen Daten endgültig und unwiderruflich löschen",
"CONFIRM_DELETE_ACCOUNT": "Kontolöschung bestätigen",
"FEEDBACK_REQUIRED": "",
"FEEDBACK_REQUIRED_FOUND_ANOTHER_SERVICE": "",
"RECOVER_TWO_FACTOR": "",
"FEEDBACK_REQUIRED_FOUND_ANOTHER_SERVICE": "Was macht der andere Dienst besser?",
"RECOVER_TWO_FACTOR": "Zwei-Faktor wiederherstellen",
"at": "",
"AUTH_NEXT": "Weiter",
"AUTH_DOWNLOAD_MOBILE_APP": "",
@@ -556,48 +556,48 @@
"SELECT_COLLECTION": "Album auswählen",
"PIN_ALBUM": "Album anheften",
"UNPIN_ALBUM": "Album lösen",
"DOWNLOAD_COMPLETE": "",
"DOWNLOADING_COLLECTION": "",
"DOWNLOAD_FAILED": "",
"DOWNLOAD_PROGRESS": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"DOWNLOAD_COMPLETE": "Download abgeschlossen",
"DOWNLOADING_COLLECTION": "Lade {{name}} herunter",
"DOWNLOAD_FAILED": "Herunterladen fehlgeschlagen",
"DOWNLOAD_PROGRESS": "{{progress.current}} / {{progress.total}} Dateien",
"CHRISTMAS": "Weihnachten",
"CHRISTMAS_EVE": "Heiligabend",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"IMAGE": "",
"VIDEO": "",
"LIVE_PHOTO": "",
"CONVERT": "",
"IMAGE": "Bild",
"VIDEO": "Video",
"LIVE_PHOTO": "Live-Foto",
"CONVERT": "Konvertieren",
"CONFIRM_EDITOR_CLOSE_MESSAGE": "",
"CONFIRM_EDITOR_CLOSE_DESCRIPTION": "",
"BRIGHTNESS": "",
"CONTRAST": "",
"SATURATION": "",
"BLUR": "",
"INVERT_COLORS": "",
"ASPECT_RATIO": "",
"SQUARE": "",
"ROTATE_LEFT": "",
"ROTATE_RIGHT": "",
"FLIP_VERTICALLY": "",
"FLIP_HORIZONTALLY": "",
"BRIGHTNESS": "Helligkeit",
"CONTRAST": "Kontrast",
"SATURATION": "Sättigung",
"BLUR": "Weichzeichnen",
"INVERT_COLORS": "Farben invertieren",
"ASPECT_RATIO": "Seitenverhältnis",
"SQUARE": "Quadrat",
"ROTATE_LEFT": "Nach links drehen",
"ROTATE_RIGHT": "Nach rechts drehen",
"FLIP_VERTICALLY": "Vertikal spiegeln",
"FLIP_HORIZONTALLY": "Horizontal spiegeln",
"DOWNLOAD_EDITED": "",
"SAVE_A_COPY_TO_ENTE": "",
"RESTORE_ORIGINAL": "",
"TRANSFORM": "",
"COLORS": "",
"FLIP": "",
"ROTATION": "",
"RESET": "",
"PHOTO_EDITOR": "",
"SAVE_A_COPY_TO_ENTE": "Kopie in Ente speichern",
"RESTORE_ORIGINAL": "Original wiederherstellen",
"TRANSFORM": "Transformieren",
"COLORS": "Farben",
"FLIP": "Spiegeln",
"ROTATION": "Drehen",
"RESET": "Zurücksetzen",
"PHOTO_EDITOR": "Foto-Editor",
"FASTER_UPLOAD": "",
"FASTER_UPLOAD_DESCRIPTION": "",
"MAGIC_SEARCH_STATUS": "",
"INDEXED_ITEMS": "",
"CAST_ALBUM_TO_TV": "",
"ENTER_CAST_PIN_CODE": "",
"PAIR_DEVICE_TO_TV": "",
"TV_NOT_FOUND": "",
"INDEXED_ITEMS": "Indizierte Elemente",
"CAST_ALBUM_TO_TV": "Album auf Fernseher wiedergeben",
"ENTER_CAST_PIN_CODE": "Gib den Code auf dem Fernseher unten ein, um dieses Gerät zu koppeln.",
"PAIR_DEVICE_TO_TV": "Geräte koppeln",
"TV_NOT_FOUND": "Fernseher nicht gefunden. Hast du die PIN korrekt eingegeben?",
"AUTO_CAST_PAIR": "",
"AUTO_CAST_PAIR_REQUIRES_CONNECTION_TO_GOOGLE": "",
"PAIR_WITH_PIN": "",
@@ -605,21 +605,21 @@
"PAIR_WITH_PIN_WORKS_FOR_ANY_LARGE_SCREEN_DEVICE": "",
"VISIT_CAST_ENTE_IO": "",
"CAST_AUTO_PAIR_FAILED": "",
"FREEHAND": "",
"FREEHAND": "Freihand",
"APPLY_CROP": "",
"PHOTO_EDIT_REQUIRED_TO_SAVE": "",
"PASSKEYS": "",
"DELETE_PASSKEY": "",
"DELETE_PASSKEY_CONFIRMATION": "",
"RENAME_PASSKEY": "",
"ADD_PASSKEY": "",
"ENTER_PASSKEY_NAME": "",
"PASSKEYS_DESCRIPTION": "",
"CREATED_AT": "",
"PASSKEY_LOGIN_FAILED": "",
"PASSKEY_LOGIN_URL_INVALID": "",
"PASSKEY_LOGIN_ERRORED": "",
"TRY_AGAIN": "",
"PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "",
"LOGIN_WITH_PASSKEY": ""
"PASSKEYS": "Passkeys",
"DELETE_PASSKEY": "Passkey löschen",
"DELETE_PASSKEY_CONFIRMATION": "Bist du sicher, dass du diesen Passkey löschen willst? Dieser Vorgang ist nicht umkehrbar.",
"RENAME_PASSKEY": "Passkey umbenennen",
"ADD_PASSKEY": "Passkey hinzufügen",
"ENTER_PASSKEY_NAME": "Passkey-Namen eingeben",
"PASSKEYS_DESCRIPTION": "Passkeys sind ein moderner und sicherer zweiter Faktor für dein Ente-Konto. Sie nutzen die biometrische Authentifizierung des Geräts für Komfort und Sicherheit.",
"CREATED_AT": "Erstellt am",
"PASSKEY_LOGIN_FAILED": "Passkey-Anmeldung fehlgeschlagen",
"PASSKEY_LOGIN_URL_INVALID": "Die Anmelde-URL ist ungültig.",
"PASSKEY_LOGIN_ERRORED": "Ein Fehler trat auf beim Anmelden mit dem Passkey auf.",
"TRY_AGAIN": "Erneut versuchen",
"PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Folge den Schritten in deinem Browser, um mit dem Anmelden fortzufahren.",
"LOGIN_WITH_PASSKEY": "Mit Passkey anmelden"
}

View File

@@ -12,7 +12,7 @@
"ENTER_NAME": "Ange namn",
"PUBLIC_UPLOADER_NAME_MESSAGE": "",
"ENTER_EMAIL": "Ange e-postadress",
"EMAIL_ERROR": "",
"EMAIL_ERROR": "Ange en giltig e-postadress",
"REQUIRED": "",
"EMAIL_SENT": "",
"CHECK_INBOX": "",
@@ -80,7 +80,7 @@
"DOWNLOAD_HIDDEN_ITEMS": "",
"COPY_OPTION": "",
"TOGGLE_FULLSCREEN": "",
"ZOOM_IN_OUT": "",
"ZOOM_IN_OUT": "Zooma in/ut",
"PREVIOUS": "",
"NEXT": "",
"TITLE_PHOTOS": "",

View File

@@ -59,11 +59,21 @@ const nextConfig = {
GIT_SHA: gitSHA(),
},
// https://dev.to/marcinwosinek/how-to-add-resolve-fallback-to-webpack-5-in-nextjs-10-i6j
// Customize the webpack configuration used by Next.js
webpack: (config, { isServer }) => {
// https://dev.to/marcinwosinek/how-to-add-resolve-fallback-to-webpack-5-in-nextjs-10-i6j
if (!isServer) {
config.resolve.fallback.fs = false;
}
// Suppress the warning "Critical dependency: require function is used
// in a way in which dependencies cannot be statically extracted" when
// import heic-convert.
//
// Upstream issue, which currently doesn't have a workaround.
// https://github.com/catdad-experiments/libheif-js/issues/23
config.ignoreWarnings = [{ module: /libheif-js/ }];
return config;
},
};

View File

@@ -188,6 +188,17 @@ export interface Electron {
* Delete the file at {@link path}.
*/
rm: (path: string) => Promise<void>;
/** Read the string contents of a file at {@link path}. */
readTextFile: (path: string) => Promise<string>;
/**
* Write a string to a file, replacing the file if it already exists.
*
* @param path The path of the file.
* @param contents The string contents to write.
*/
writeFile: (path: string, contents: string) => Promise<void>;
};
/*
@@ -300,12 +311,6 @@ export interface Electron {
) => Promise<void>;
// - FS legacy
saveStreamToDisk: (
path: string,
fileStream: ReadableStream,
) => Promise<void>;
saveFileToDisk: (path: string, contents: string) => Promise<void>;
readTextFile: (path: string) => Promise<string>;
isFolder: (dirPath: string) => Promise<boolean>;
// - Upload

View File

@@ -1,7 +1,11 @@
export async function sleep(time: number) {
await new Promise((resolve) => {
setTimeout(() => resolve(null), time);
});
/**
* Wait for {@link ms} milliseconds
*
* This function is a promisified `setTimeout`. It returns a promise that
* resolves after {@link ms} milliseconds.
*/
export async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
export function downloadAsFile(filename: string, content: string) {