Merge branch 'main' into f-droid
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
116
desktop/src/main/stream.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' +
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
157
mobile/lib/utils/multipart_upload_util.dart
Normal file
157
mobile/lib/utils/multipart_upload_util.dart
Normal 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('"', '');
|
||||
|
||||
try {
|
||||
await _dio.post(
|
||||
completeURL,
|
||||
data: body,
|
||||
options: Options(
|
||||
contentType: "text/xml",
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Logger("MultipartUpload").severe(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
41
mobile/lib/utils/xml_parser_util.dart
Normal file
41
mobile/lib/utils/xml_parser_util.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
web/apps/photos/src/utils/native-stream.ts
Normal file
39
web/apps/photos/src/utils/native-stream.ts
Normal 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}`,
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user