Просмотр исходного кода

Merge remote-tracking branch 'origin/main' into mobile-resumable

Prateek Sunal 1 год назад
Родитель
Сommit
c29beab8d6
89 измененных файлов с 1334 добавлено и 1390 удалено
  1. 4 0
      auth/lib/l10n/arb/app_de.arb
  2. 3 1
      auth/lib/l10n/arb/app_it.arb
  3. 7 0
      auth/lib/l10n/arb/app_pt.arb
  4. 1 0
      auth/lib/l10n/arb/app_sv.arb
  5. 2 2
      desktop/package.json
  6. 71 14
      desktop/src/main.ts
  7. 5 71
      desktop/src/main/fs.ts
  8. 25 32
      desktop/src/main/ipc.ts
  9. 1 1
      desktop/src/main/services/ffmpeg.ts
  10. 1 1
      desktop/src/main/services/imageProcessor.ts
  11. 1 1
      desktop/src/main/services/ml-clip.ts
  12. 1 1
      desktop/src/main/services/ml.ts
  13. 4 4
      desktop/src/main/services/watch.ts
  14. 116 0
      desktop/src/main/stream.ts
  15. 25 28
      desktop/src/preload.ts
  16. 15 15
      desktop/src/types/ipc.ts
  17. 104 96
      desktop/yarn.lock
  18. 2 0
      mobile/analysis_options.yaml
  19. 4 14
      mobile/ios/Podfile.lock
  20. 0 4
      mobile/ios/Runner.xcodeproj/project.pbxproj
  21. 4 34
      mobile/lib/app.dart
  22. 1 0
      mobile/lib/generated/intl/messages_cs.dart
  23. 1 0
      mobile/lib/generated/intl/messages_de.dart
  24. 1 0
      mobile/lib/generated/intl/messages_en.dart
  25. 1 0
      mobile/lib/generated/intl/messages_es.dart
  26. 1 0
      mobile/lib/generated/intl/messages_fr.dart
  27. 1 0
      mobile/lib/generated/intl/messages_it.dart
  28. 1 0
      mobile/lib/generated/intl/messages_ko.dart
  29. 1 0
      mobile/lib/generated/intl/messages_nl.dart
  30. 1 0
      mobile/lib/generated/intl/messages_no.dart
  31. 1 0
      mobile/lib/generated/intl/messages_pl.dart
  32. 1 0
      mobile/lib/generated/intl/messages_pt.dart
  33. 1 0
      mobile/lib/generated/intl/messages_zh.dart
  34. 10 0
      mobile/lib/generated/l10n.dart
  35. 2 1
      mobile/lib/l10n/intl_cs.arb
  36. 2 1
      mobile/lib/l10n/intl_de.arb
  37. 2 1
      mobile/lib/l10n/intl_en.arb
  38. 2 1
      mobile/lib/l10n/intl_es.arb
  39. 2 1
      mobile/lib/l10n/intl_fr.arb
  40. 2 1
      mobile/lib/l10n/intl_it.arb
  41. 2 1
      mobile/lib/l10n/intl_ko.arb
  42. 2 1
      mobile/lib/l10n/intl_nl.arb
  43. 2 1
      mobile/lib/l10n/intl_no.arb
  44. 2 1
      mobile/lib/l10n/intl_pl.arb
  45. 2 1
      mobile/lib/l10n/intl_pt.arb
  46. 2 1
      mobile/lib/l10n/intl_zh.arb
  47. 13 3
      mobile/lib/models/file/file.dart
  48. 50 0
      mobile/lib/services/home_widget_service.dart
  49. 6 5
      mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart
  50. 3 1
      mobile/lib/services/sync_service.dart
  51. 7 5
      mobile/lib/ui/viewer/search/search_widget.dart
  52. 3 2
      mobile/lib/utils/file_uploader.dart
  53. 6 54
      mobile/pubspec.lock
  54. 2 8
      mobile/pubspec.yaml
  55. 5 2
      server/configurations/local.yaml
  56. 1 1
      server/pkg/repo/public_collection.go
  57. 18 49
      web/apps/cast/src/components/PhotoAuditorium.tsx
  58. 0 95
      web/apps/cast/src/components/Theatre/PhotoAuditorium.tsx
  59. 0 55
      web/apps/cast/src/components/Theatre/VideoAuditorium.tsx
  60. 0 30
      web/apps/cast/src/components/Theatre/index.tsx
  61. 46 83
      web/apps/cast/src/pages/slideshow.tsx
  62. 1 1
      web/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx
  63. 2 1
      web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx
  64. 1 1
      web/apps/photos/src/components/Sidebar/UtilitySection.tsx
  65. 1 1
      web/apps/photos/src/components/Upload/Uploader.tsx
  66. 364 0
      web/apps/photos/src/components/WatchFolder.tsx
  67. 0 152
      web/apps/photos/src/components/WatchFolder/index.tsx
  68. 0 23
      web/apps/photos/src/components/WatchFolder/mappingEntry/entryHeading.tsx
  69. 0 69
      web/apps/photos/src/components/WatchFolder/mappingEntry/index.tsx
  70. 0 33
      web/apps/photos/src/components/WatchFolder/mappingEntry/mappingEntryOptions.tsx
  71. 0 26
      web/apps/photos/src/components/WatchFolder/mappingList/index.tsx
  72. 0 15
      web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/checkmarkIcon.tsx
  73. 0 33
      web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/noMappingsContent.tsx
  74. 0 23
      web/apps/photos/src/components/WatchFolder/styledComponents.tsx
  75. 6 6
      web/apps/photos/src/services/download/index.ts
  76. 8 8
      web/apps/photos/src/services/export/index.ts
  77. 1 1
      web/apps/photos/src/services/upload/uploadManager.ts
  78. 113 17
      web/apps/photos/src/services/watch.ts
  79. 0 5
      web/apps/photos/src/services/watchFolder/utils.ts
  80. 0 73
      web/apps/photos/src/services/watchFolder/watchFolderEventHandlers.ts
  81. 17 24
      web/apps/photos/src/utils/file/index.ts
  82. 58 0
      web/apps/photos/src/utils/native-stream.ts
  83. 0 26
      web/apps/photos/src/utils/watch/index.ts
  84. 97 97
      web/packages/next/locales/de-DE/translation.json
  85. 2 2
      web/packages/next/locales/sv-SE/translation.json
  86. 11 1
      web/packages/next/next.config.base.js
  87. 0 14
      web/packages/next/types/file.ts
  88. 42 10
      web/packages/next/types/ipc.ts
  89. 8 4
      web/packages/shared/utils/index.ts

+ 4 - 0
auth/lib/l10n/arb/app_de.arb

@@ -113,12 +113,14 @@
   "copied": "Kopiert",
   "pleaseTryAgain": "Bitte versuchen Sie es erneut",
   "existingUser": "Bestehender Benutzer",
+  "newUser": "Neu bei Ente",
   "delete": "Löschen",
   "enterYourPasswordHint": "Geben Sie Ihr Passwort ein",
   "forgotPassword": "Passwort vergessen",
   "oops": "Hopla",
   "suggestFeatures": "Features vorschlagen",
   "faq": "FAQ",
+  "faq_q_1": "Wie sicher ist Auth?",
   "faq_q_2": "Kann ich auf meine Codes auf dem Desktop zugreifen?",
   "faq_a_2": "Sie können auf Ihre Codes im Web via auth.ente.io zugreifen.",
   "faq_q_3": "Wie kann ich Codes löschen?",
@@ -193,6 +195,7 @@
   "recoveryKeySaveDescription": "Wir speichern diesen Schlüssel nicht. Sichern sie dieses diesen Schlüssel bestehend aus 24 Wörtern an einem sicheren Platz.",
   "doThisLater": "Auf später verschieben",
   "saveKey": "Schlüssel speichern",
+  "save": "Speichern",
   "back": "Zurück",
   "createAccount": "Account erstellen",
   "passwordStrength": "Passwortstärke: {passwordStrengthValue}",
@@ -400,6 +403,7 @@
   "doNotSignOut": "Nicht abmelden",
   "hearUsWhereTitle": "Wie hast du von Ente erfahren? (optional)",
   "hearUsExplanation": "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!",
+  "recoveryKeySaved": "Wiederherstellungsschlüssel im Downloads-Ordner gespeichert!",
   "waitingForBrowserRequest": "Warten auf Browseranfrage...",
   "waitingForVerification": "Warte auf Bestätigung...",
   "passkey": "Passkey",

+ 3 - 1
auth/lib/l10n/arb/app_it.arb

@@ -190,6 +190,7 @@
   "recoveryKeySaveDescription": "Non memorizziamo questa chiave, per favore salva questa chiave di 24 parole in un posto sicuro.",
   "doThisLater": "Fallo più tardi",
   "saveKey": "Salva chiave",
+  "save": "Salva",
   "back": "Indietro",
   "createAccount": "Crea account",
   "passwordStrength": "Forza password: {passwordStrengthValue}",
@@ -396,5 +397,6 @@
   "signOutOtherDevices": "Esci dagli altri dispositivi",
   "doNotSignOut": "Non uscire",
   "hearUsWhereTitle": "Dove hai sentito parlare di Ente? (opzionale)",
-  "hearUsExplanation": "Non teniamo traccia delle installazioni dell'app. Sarebbe utile se ci dicessi dove ci hai trovato!"
+  "hearUsExplanation": "Non teniamo traccia delle installazioni dell'app. Sarebbe utile se ci dicessi dove ci hai trovato!",
+  "passkey": "Passkey"
 }

+ 7 - 0
auth/lib/l10n/arb/app_pt.arb

@@ -78,12 +78,14 @@
   "data": "Dados",
   "importCodes": "Importar códigos",
   "importTypePlainText": "Texto simples",
+  "importTypeEnteEncrypted": "Exportação Ente criptografada",
   "passwordForDecryptingExport": "Senha para descriptografar a exportação",
   "passwordEmptyError": "O campo senha não pode estar vazio",
   "importFromApp": "Importar códigos do {appName}",
   "importGoogleAuthGuide": "Exporte suas contas do Google Authenticator para um QR code usando a opção \"Transferir contas\". Então, usando outro dispositivo, escaneie o QR code.\n\nDica: Você pode usar a câmera do seu notebook para fotografar o QR code.",
   "importSelectJsonFile": "Selecione o arquivo JSON",
   "importSelectAppExport": "Selecione o arquivo de exportação do aplicativo {appName}",
+  "importEnteEncGuide": "Selecione o arquivo JSON criptografado exportado do Ente",
   "importRaivoGuide": "Use a opção \"Exportar OTPs para arquivo Zip\" nas configurações do Raivo.\n\nExtraia o arquivo zip e importe o arquivo JSON.",
   "importBitwardenGuide": "Use a opção \"Exportar cofre\" nas configurações do Bitwarden e importe o arquivo JSON não criptografado.",
   "importAegisGuide": "Use a opção \"Exportar cofre\" nas Configurações do Aegis.\n\nSe o seu cofre estiver criptografado, você precisará inserir a senha do cofre para descriptografá-lo.",
@@ -113,18 +115,22 @@
   "copied": "Copiado",
   "pleaseTryAgain": "Por favor, tente novamente",
   "existingUser": "Usuário Existente",
+  "newUser": "Novo no Ente",
   "delete": "Excluir",
   "enterYourPasswordHint": "Insira sua senha",
   "forgotPassword": "Esqueci a senha",
   "oops": "Oops",
   "suggestFeatures": "Sugerir funcionalidades",
   "faq": "Perguntas frequentes",
+  "faq_q_1": "Quão seguro é o Auth?",
+  "faq_a_1": "Todos os códigos que você faz backup via Auth são armazenados criptografados de ponta a ponta. Isso significa que somente você pode acessar seus códigos. Nossos aplicativos são de código aberto e nossa criptografia foi auditada externamente.",
   "faq_q_2": "Eu posso acessar meus códigos no computador?",
   "faq_a_2": "Você pode acessar seus códigos na web em auth.ente.io.",
   "faq_q_3": "Como faço para excluir códigos?",
   "faq_a_3": "Você pode excluir um código deslizando para a esquerda sobre esse item.",
   "faq_q_4": "Como posso apoiar este projeto?",
   "faq_a_4": "Você pode apoiar o desenvolvimento deste projeto assinando nosso aplicativo de Fotos em ente.io.",
+  "faq_q_5": "Como posso ativar o bloqueio facial no Auth",
   "faq_a_5": "Você pode ativar o bloqueio facial em Configurações → Segurança → Tela de bloqueio.",
   "somethingWentWrongMessage": "Algo deu errado. Por favor, tente outra vez",
   "leaveFamily": "Sair da família",
@@ -344,6 +350,7 @@
   "deleteCodeAuthMessage": "Autenticar para excluir o código",
   "showQRAuthMessage": "Autenticar para mostrar o QR code",
   "confirmAccountDeleteTitle": "Confirmar exclusão de conta",
+  "confirmAccountDeleteMessage": "Esta conta está vinculada a outros aplicativos Ente, se você usa algum.\n\nSeus dados enviados, em todos os aplicativos Ente, serão agendados para exclusão, e sua conta será excluída permanentemente.",
   "androidBiometricHint": "Verificar identidade",
   "@androidBiometricHint": {
     "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

+ 1 - 0
auth/lib/l10n/arb/app_sv.arb

@@ -61,6 +61,7 @@
   "incorrectPasswordTitle": "Felaktigt lösenord",
   "welcomeBack": "Välkommen tillbaka!",
   "changePassword": "Ändra lösenord",
+  "importCodes": "Importera koder",
   "cancel": "Avbryt",
   "yes": "Ja",
   "no": "Nej",

+ 2 - 2
desktop/package.json

@@ -44,8 +44,8 @@
         "electron-builder-notarize": "^1.5",
         "eslint": "^8",
         "prettier": "^3",
-        "prettier-plugin-organize-imports": "^3.2",
-        "prettier-plugin-packagejson": "^2.4",
+        "prettier-plugin-organize-imports": "^3",
+        "prettier-plugin-packagejson": "^2",
         "shx": "^0.3",
         "typescript": "^5"
     },

+ 71 - 14
desktop/src/main.ts

@@ -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,6 +27,7 @@ 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";
 
 /**
@@ -58,6 +59,21 @@ export const allowWindowClose = (): void => {
     shouldAllowWindowClose = true;
 };
 
+/**
+ * Log a standard startup banner.
+ *
+ * This helps us identify app starts and other environment details in the logs.
+ */
+const logStartupBanner = () => {
+    const version = isDev ? "dev" : app.getVersion();
+    log.info(`Starting ente-photos-desktop ${version}`);
+
+    const platform = process.platform;
+    const osRelease = os.release();
+    const systemVersion = process.getSystemVersion();
+    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
@@ -74,18 +90,57 @@ export const allowWindowClose = (): void => {
 const setupRendererServer = () => serveNextAt(rendererURL);
 
 /**
- * Log a standard startup banner.
+ * Register privileged schemes.
  *
- * This helps us identify app starts and other environment details in the logs.
+ * 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 logStartupBanner = () => {
-    const version = isDev ? "dev" : app.getVersion();
-    log.info(`Starting ente-photos-desktop ${version}`);
+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.
 
-    const platform = process.platform;
-    const osRelease = os.release();
-    const systemVersion = process.getSystemVersion();
-    log.info("Running on", { platform, osRelease, systemVersion });
+                // 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,
+            },
+        },
+    ]);
 };
 
 /**
@@ -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);

+ 5 - 71
desktop/src/main/fs.ts

@@ -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));
-
-/**
- * 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 fsReadTextFile = async (filePath: string) =>
+    fs.readFile(filePath, "utf-8");
 
-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;

+ 25 - 32
desktop/src/main/ipc.ts

@@ -10,7 +10,7 @@
 
 import type { FSWatcher } from "chokidar";
 import { ipcMain } from "electron/main";
-import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc";
+import type { ElectronFile, FILE_PATH_TYPE, FolderWatch } from "../types/ipc";
 import {
     selectDirectory,
     showUploadDirsDialog,
@@ -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
@@ -249,13 +242,13 @@ export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
 
     ipcMain.handle(
         "updateWatchMappingSyncedFiles",
-        (_, folderPath: string, files: WatchMapping["syncedFiles"]) =>
+        (_, folderPath: string, files: FolderWatch["syncedFiles"]) =>
             updateWatchMappingSyncedFiles(folderPath, files),
     );
 
     ipcMain.handle(
         "updateWatchMappingIgnoredFiles",
-        (_, folderPath: string, files: WatchMapping["ignoredFiles"]) =>
+        (_, folderPath: string, files: FolderWatch["ignoredFiles"]) =>
             updateWatchMappingIgnoredFiles(folderPath, files),
     );
 };

+ 1 - 1
desktop/src/main/services/ffmpeg.ts

@@ -2,8 +2,8 @@ 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 log from "../log";
+import { writeStream } from "../stream";
 import { generateTempFilePath, getTempDirPath } from "../temp";
 import { execAsync } from "../util";
 

+ 1 - 1
desktop/src/main/services/imageProcessor.ts

@@ -2,9 +2,9 @@ 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 log from "../log";
 import { isPlatform } from "../platform";
+import { writeStream } from "../stream";
 import { generateTempFilePath } from "../temp";
 import { execAsync, isDev } from "../util";
 import { deleteTempFile } from "./ffmpeg";

+ 1 - 1
desktop/src/main/services/ml-clip.ts

@@ -11,8 +11,8 @@ 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 log from "../log";
+import { writeStream } from "../stream";
 import { generateTempFilePath } from "../temp";
 import { deleteTempFile } from "./ffmpeg";
 import {

+ 1 - 1
desktop/src/main/services/ml.ts

@@ -15,8 +15,8 @@ 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 log from "../log";
+import { writeStream } from "../stream";
 
 /**
  * Download the model named {@link modelName} if we don't already have it.

+ 4 - 4
desktop/src/main/services/watch.ts

@@ -1,6 +1,6 @@
 import type { FSWatcher } from "chokidar";
 import ElectronLog from "electron-log";
-import { WatchMapping, WatchStoreType } from "../../types/ipc";
+import { FolderWatch, WatchStoreType } from "../../types/ipc";
 import { watchStore } from "../stores/watch.store";
 
 export const addWatchMapping = async (
@@ -28,7 +28,7 @@ export const addWatchMapping = async (
     setWatchMappings(watchMappings);
 };
 
-function isMappingPresent(watchMappings: WatchMapping[], folderPath: string) {
+function isMappingPresent(watchMappings: FolderWatch[], folderPath: string) {
     const watchMapping = watchMappings?.find(
         (mapping) => mapping.folderPath === folderPath,
     );
@@ -59,7 +59,7 @@ export const removeWatchMapping = async (
 
 export function updateWatchMappingSyncedFiles(
     folderPath: string,
-    files: WatchMapping["syncedFiles"],
+    files: FolderWatch["syncedFiles"],
 ): void {
     const watchMappings = getWatchMappings();
     const watchMapping = watchMappings.find(
@@ -76,7 +76,7 @@ export function updateWatchMappingSyncedFiles(
 
 export function updateWatchMappingIgnoredFiles(
     folderPath: string,
-    files: WatchMapping["ignoredFiles"],
+    files: FolderWatch["ignoredFiles"],
 ): void {
     const watchMappings = getWatchMappings();
     const watchMapping = watchMappings.find(

+ 116 - 0
desktop/src/main/stream.ts

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

+ 25 - 28
desktop/src/preload.ts

@@ -45,7 +45,7 @@ import type {
     AppUpdateInfo,
     ElectronFile,
     FILE_PATH_TYPE,
-    WatchMapping,
+    FolderWatch,
 } from "./types/ipc";
 
 // - General
@@ -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
@@ -212,34 +220,23 @@ const addWatchMapping = (
 const removeWatchMapping = (folderPath: string): Promise<void> =>
     ipcRenderer.invoke("removeWatchMapping", folderPath);
 
-const getWatchMappings = (): Promise<WatchMapping[]> =>
+const getWatchMappings = (): Promise<FolderWatch[]> =>
     ipcRenderer.invoke("getWatchMappings");
 
 const updateWatchMappingSyncedFiles = (
     folderPath: string,
-    files: WatchMapping["syncedFiles"],
+    files: FolderWatch["syncedFiles"],
 ): Promise<void> =>
     ipcRenderer.invoke("updateWatchMappingSyncedFiles", folderPath, files);
 
 const updateWatchMappingIgnoredFiles = (
     folderPath: string,
-    files: WatchMapping["ignoredFiles"],
+    files: FolderWatch["ignoredFiles"],
 ): Promise<void> =>
     ipcRenderer.invoke("updateWatchMappingIgnoredFiles", folderPath, files);
 
 // - 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

+ 15 - 15
desktop/src/types/ipc.ts

@@ -5,6 +5,20 @@
  * See [Note: types.ts <-> preload.ts <-> ipc.ts]
  */
 
+export interface FolderWatch {
+    rootFolderName: string;
+    uploadStrategy: number;
+    folderPath: string;
+    syncedFiles: FolderWatchSyncedFile[];
+    ignoredFiles: string[];
+}
+
+export interface FolderWatchSyncedFile {
+    path: string;
+    uploadedFileID: number;
+    collectionID: number;
+}
+
 /**
  * Errors that have special semantics on the web side.
  *
@@ -52,22 +66,8 @@ export interface ElectronFile {
     arrayBuffer: () => Promise<Uint8Array>;
 }
 
-interface WatchMappingSyncedFile {
-    path: string;
-    uploadedFileID: number;
-    collectionID: number;
-}
-
-export interface WatchMapping {
-    rootFolderName: string;
-    uploadStrategy: number;
-    folderPath: string;
-    syncedFiles: WatchMappingSyncedFile[];
-    ignoredFiles: string[];
-}
-
 export interface WatchStoreType {
-    mappings: WatchMapping[];
+    mappings: FolderWatch[];
 }
 
 export enum FILE_PATH_TYPE {

+ 104 - 96
desktop/yarn.lock

@@ -125,7 +125,7 @@
   dependencies:
     eslint-visitor-keys "^3.3.0"
 
-"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1":
+"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1":
   version "4.10.0"
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63"
   integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==
@@ -285,7 +285,7 @@
   resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4"
   integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==
 
-"@types/json-schema@^7.0.12":
+"@types/json-schema@^7.0.15":
   version "7.0.15"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
   integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
@@ -303,9 +303,9 @@
   integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==
 
 "@types/node@*", "@types/node@^20.9.0":
-  version "20.11.30"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.30.tgz#9c33467fc23167a347e73834f788f4b9f399d66f"
-  integrity sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==
+  version "20.12.7"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.7.tgz#04080362fa3dd6c5822061aa3124f5c152cff384"
+  integrity sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==
   dependencies:
     undici-types "~5.26.4"
 
@@ -334,7 +334,7 @@
   dependencies:
     "@types/node" "*"
 
-"@types/semver@^7.5.0":
+"@types/semver@^7.5.8":
   version "7.5.8"
   resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
   integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==
@@ -352,90 +352,90 @@
     "@types/node" "*"
 
 "@typescript-eslint/eslint-plugin@^7":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz#de61c3083842fc6ac889d2fc83c9a96b55ab8328"
-  integrity sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==
-  dependencies:
-    "@eslint-community/regexpp" "^4.5.1"
-    "@typescript-eslint/scope-manager" "7.4.0"
-    "@typescript-eslint/type-utils" "7.4.0"
-    "@typescript-eslint/utils" "7.4.0"
-    "@typescript-eslint/visitor-keys" "7.4.0"
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz#1f5df5cda490a0bcb6fbdd3382e19f1241024242"
+  integrity sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A==
+  dependencies:
+    "@eslint-community/regexpp" "^4.10.0"
+    "@typescript-eslint/scope-manager" "7.6.0"
+    "@typescript-eslint/type-utils" "7.6.0"
+    "@typescript-eslint/utils" "7.6.0"
+    "@typescript-eslint/visitor-keys" "7.6.0"
     debug "^4.3.4"
     graphemer "^1.4.0"
-    ignore "^5.2.4"
+    ignore "^5.3.1"
     natural-compare "^1.4.0"
-    semver "^7.5.4"
-    ts-api-utils "^1.0.1"
+    semver "^7.6.0"
+    ts-api-utils "^1.3.0"
 
 "@typescript-eslint/parser@^7":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.4.0.tgz#540f4321de1e52b886c0fa68628af1459954c1f1"
-  integrity sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==
-  dependencies:
-    "@typescript-eslint/scope-manager" "7.4.0"
-    "@typescript-eslint/types" "7.4.0"
-    "@typescript-eslint/typescript-estree" "7.4.0"
-    "@typescript-eslint/visitor-keys" "7.4.0"
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.6.0.tgz#0aca5de3045d68b36e88903d15addaf13d040a95"
+  integrity sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg==
+  dependencies:
+    "@typescript-eslint/scope-manager" "7.6.0"
+    "@typescript-eslint/types" "7.6.0"
+    "@typescript-eslint/typescript-estree" "7.6.0"
+    "@typescript-eslint/visitor-keys" "7.6.0"
     debug "^4.3.4"
 
-"@typescript-eslint/scope-manager@7.4.0":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz#acfc69261f10ece7bf7ece1734f1713392c3655f"
-  integrity sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==
+"@typescript-eslint/scope-manager@7.6.0":
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz#1e9972f654210bd7500b31feadb61a233f5b5e9d"
+  integrity sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w==
   dependencies:
-    "@typescript-eslint/types" "7.4.0"
-    "@typescript-eslint/visitor-keys" "7.4.0"
+    "@typescript-eslint/types" "7.6.0"
+    "@typescript-eslint/visitor-keys" "7.6.0"
 
-"@typescript-eslint/type-utils@7.4.0":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz#cfcaab21bcca441c57da5d3a1153555e39028cbd"
-  integrity sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==
+"@typescript-eslint/type-utils@7.6.0":
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz#644f75075f379827d25fe0713e252ccd4e4a428c"
+  integrity sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw==
   dependencies:
-    "@typescript-eslint/typescript-estree" "7.4.0"
-    "@typescript-eslint/utils" "7.4.0"
+    "@typescript-eslint/typescript-estree" "7.6.0"
+    "@typescript-eslint/utils" "7.6.0"
     debug "^4.3.4"
-    ts-api-utils "^1.0.1"
+    ts-api-utils "^1.3.0"
 
-"@typescript-eslint/types@7.4.0":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.4.0.tgz#ee9dafa75c99eaee49de6dcc9348b45d354419b6"
-  integrity sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==
+"@typescript-eslint/types@7.6.0":
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.6.0.tgz#53dba7c30c87e5f10a731054266dd905f1fbae38"
+  integrity sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ==
 
-"@typescript-eslint/typescript-estree@7.4.0":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz#12dbcb4624d952f72c10a9f4431284fca24624f4"
-  integrity sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==
+"@typescript-eslint/typescript-estree@7.6.0":
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz#112a3775563799fd3f011890ac8322f80830ac17"
+  integrity sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw==
   dependencies:
-    "@typescript-eslint/types" "7.4.0"
-    "@typescript-eslint/visitor-keys" "7.4.0"
+    "@typescript-eslint/types" "7.6.0"
+    "@typescript-eslint/visitor-keys" "7.6.0"
     debug "^4.3.4"
     globby "^11.1.0"
     is-glob "^4.0.3"
-    minimatch "9.0.3"
-    semver "^7.5.4"
-    ts-api-utils "^1.0.1"
+    minimatch "^9.0.4"
+    semver "^7.6.0"
+    ts-api-utils "^1.3.0"
 
-"@typescript-eslint/utils@7.4.0":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.4.0.tgz#d889a0630cab88bddedaf7c845c64a00576257bd"
-  integrity sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==
+"@typescript-eslint/utils@7.6.0":
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.6.0.tgz#e400d782280b6f724c8a1204269d984c79202282"
+  integrity sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA==
   dependencies:
     "@eslint-community/eslint-utils" "^4.4.0"
-    "@types/json-schema" "^7.0.12"
-    "@types/semver" "^7.5.0"
-    "@typescript-eslint/scope-manager" "7.4.0"
-    "@typescript-eslint/types" "7.4.0"
-    "@typescript-eslint/typescript-estree" "7.4.0"
-    semver "^7.5.4"
-
-"@typescript-eslint/visitor-keys@7.4.0":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz#0c8ff2c1f8a6fe8d7d1a57ebbd4a638e86a60a94"
-  integrity sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==
-  dependencies:
-    "@typescript-eslint/types" "7.4.0"
-    eslint-visitor-keys "^3.4.1"
+    "@types/json-schema" "^7.0.15"
+    "@types/semver" "^7.5.8"
+    "@typescript-eslint/scope-manager" "7.6.0"
+    "@typescript-eslint/types" "7.6.0"
+    "@typescript-eslint/typescript-estree" "7.6.0"
+    semver "^7.6.0"
+
+"@typescript-eslint/visitor-keys@7.6.0":
+  version "7.6.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz#d1ce13145844379021e1f9bd102c1d78946f4e76"
+  integrity sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==
+  dependencies:
+    "@typescript-eslint/types" "7.6.0"
+    eslint-visitor-keys "^3.4.3"
 
 "@ungap/structured-clone@^1.2.0":
   version "1.2.0"
@@ -1140,9 +1140,9 @@ ejs@^3.1.8:
     jake "^10.8.5"
 
 electron-builder-notarize@^1.5:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/electron-builder-notarize/-/electron-builder-notarize-1.5.1.tgz#e00b868a67ef20a77f00017606626f24fdbdc445"
-  integrity sha512-xS7s9gE+1AcJIuJ4DU/LqCrmRypE1zOR/6b66egKzgP/UVh9YSa7rINos34gF/KcueNDQU39HcXcCEKiEI5wPQ==
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/electron-builder-notarize/-/electron-builder-notarize-1.5.2.tgz#540185b57a336fc6eec01bfe092a3b4764459255"
+  integrity sha512-vo6RGgIFYxMk2yp59N4NsvmAYfB7ncYi6gV9Fcq2TVKxEn2tPXrSjIKB2e/pu+5iXIY6BHNZNXa75F3DHgOOLA==
   dependencies:
     dotenv "^8.2.0"
     electron-notarize "^1.1.1"
@@ -1215,9 +1215,9 @@ electron-updater@^6.1:
     tiny-typed-emitter "^2.1.0"
 
 electron@^29:
-  version "29.1.5"
-  resolved "https://registry.yarnpkg.com/electron/-/electron-29.1.5.tgz#b745b4d201c1ac9f84d6aa034126288dde34d5a1"
-  integrity sha512-1uWGRw/ffA62lcrklxGUgVxVtOHojsg/nwsYr+/F9cVjipZJn8iPv/ABGIIexhmUqWcho8BqfTJ4osCBa29gBg==
+  version "29.3.0"
+  resolved "https://registry.yarnpkg.com/electron/-/electron-29.3.0.tgz#8e65cb08e9c0952c66d3196e1b5c811c43b8c5b0"
+  integrity sha512-ZxFKm0/v48GSoBuO3DdnMlCYXefEUKUHLMsKxyXY4nZGgzbBKpF/X8haZa2paNj23CLfsCKBOtfc2vsEQiOOsA==
   dependencies:
     "@electron/get" "^2.0.0"
     "@types/node" "^20.9.0"
@@ -1835,7 +1835,7 @@ ieee754@^1.1.13:
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
-ignore@^5.2.0, ignore@^5.2.4:
+ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1:
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
   integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
@@ -2190,13 +2190,6 @@ mimic-response@^3.1.0:
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
   integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
 
-minimatch@9.0.3, minimatch@^9.0.1:
-  version "9.0.3"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
-  integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
-  dependencies:
-    brace-expansion "^2.0.1"
-
 minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@@ -2211,6 +2204,20 @@ minimatch@^5.0.1, minimatch@^5.1.1:
   dependencies:
     brace-expansion "^2.0.1"
 
+minimatch@^9.0.1:
+  version "9.0.3"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
+  integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
+  dependencies:
+    brace-expansion "^2.0.1"
+
+minimatch@^9.0.4:
+  version "9.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51"
+  integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==
+  dependencies:
+    brace-expansion "^2.0.1"
+
 minimist@^1.2.3, minimist@^1.2.6:
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
@@ -2482,17 +2489,17 @@ prelude-ls@^1.2.1:
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
   integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
 
-prettier-plugin-organize-imports@^3.2:
+prettier-plugin-organize-imports@^3:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz#77967f69d335e9c8e6e5d224074609309c62845e"
   integrity sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==
 
-prettier-plugin-packagejson@^2.4:
-  version "2.4.12"
-  resolved "https://registry.yarnpkg.com/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.4.12.tgz#eeb917dad83ae42d0caccc9f26d3728b5c4f2434"
-  integrity sha512-hifuuOgw5rHHTdouw9VrhT8+Nd7UwxtL1qco8dUfd4XUFQL6ia3xyjSxhPQTsGnSYFraTWy5Omb+MZm/OWDTpQ==
+prettier-plugin-packagejson@^2:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.5.0.tgz#23d2cb8b1f7840702d35e3a5078e564ea0bc63e0"
+  integrity sha512-6XkH3rpin5QEQodBSVNg+rBo4r91g/1mCaRwS1YGdQJZ6jwqrg2UchBsIG9tpS1yK1kNBvOt84OILsX8uHzBGg==
   dependencies:
-    sort-package-json "2.8.0"
+    sort-package-json "2.10.0"
     synckit "0.9.0"
 
 prettier@^3:
@@ -2711,7 +2718,7 @@ semver@^6.2.0:
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
   integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
-semver@^7.3.2, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4:
+semver@^7.3.2, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.6.0:
   version "7.6.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
   integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
@@ -2800,10 +2807,10 @@ sort-object-keys@^1.1.3:
   resolved "https://registry.yarnpkg.com/sort-object-keys/-/sort-object-keys-1.1.3.tgz#bff833fe85cab147b34742e45863453c1e190b45"
   integrity sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==
 
-sort-package-json@2.8.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-2.8.0.tgz#6a46439ad0fef77f091e678e103f03ecbea575c8"
-  integrity sha512-PxeNg93bTJWmDGnu0HADDucoxfFiKkIr73Kv85EBThlI1YQPdc0XovBgg2llD0iABZbu2SlKo8ntGmOP9wOj/g==
+sort-package-json@2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-2.10.0.tgz#6be07424bf3b7db9fbb1bdd69e7945f301026d8a"
+  integrity sha512-MYecfvObMwJjjJskhxYfuOADkXp1ZMMnCFC8yhp+9HDsk7HhR336hd7eiBs96lTXfiqmUNI+WQCeCMRBhl251g==
   dependencies:
     detect-indent "^7.0.1"
     detect-newline "^4.0.0"
@@ -2811,6 +2818,7 @@ sort-package-json@2.8.0:
     git-hooks-list "^3.0.0"
     globby "^13.1.2"
     is-plain-obj "^4.1.0"
+    semver "^7.6.0"
     sort-object-keys "^1.1.3"
 
 source-map-support@^0.5.19:
@@ -3018,7 +3026,7 @@ truncate-utf8-bytes@^1.0.0:
   dependencies:
     utf8-byte-length "^1.0.1"
 
-ts-api-utils@^1.0.1:
+ts-api-utils@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1"
   integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==

+ 2 - 0
mobile/analysis_options.yaml

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

+ 4 - 14
mobile/ios/Podfile.lock

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

+ 0 - 4
mobile/ios/Runner.xcodeproj/project.pbxproj

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

+ 4 - 34
mobile/lib/app.dart

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

+ 1 - 0
mobile/lib/generated/intl/messages_cs.dart

@@ -55,6 +55,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Modify your query, or try searching for"),
         "moveToHiddenAlbum":
             MessageLookupByLibrary.simpleMessage("Move to hidden album"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "selectALocation":
             MessageLookupByLibrary.simpleMessage("Select a location"),
         "selectALocationFirst":

+ 1 - 0
mobile/lib/generated/intl/messages_de.dart

@@ -1213,6 +1213,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "scanThisBarcodeWithnyourAuthenticatorApp":
             MessageLookupByLibrary.simpleMessage(
                 "Scanne diesen Code mit \ndeiner Authentifizierungs-App"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "searchAlbumsEmptySection":
             MessageLookupByLibrary.simpleMessage("Alben"),
         "searchByAlbumNameHint":

+ 1 - 0
mobile/lib/generated/intl/messages_en.dart

@@ -1175,6 +1175,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "scanThisBarcodeWithnyourAuthenticatorApp":
             MessageLookupByLibrary.simpleMessage(
                 "Scan this barcode with\nyour authenticator app"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "searchAlbumsEmptySection":
             MessageLookupByLibrary.simpleMessage("Albums"),
         "searchByAlbumNameHint":

+ 1 - 0
mobile/lib/generated/intl/messages_es.dart

@@ -1044,6 +1044,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "scanThisBarcodeWithnyourAuthenticatorApp":
             MessageLookupByLibrary.simpleMessage(
                 "Escanea este código QR con tu aplicación de autenticación"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "searchByAlbumNameHint":
             MessageLookupByLibrary.simpleMessage("Nombre del álbum"),
         "searchByExamples": MessageLookupByLibrary.simpleMessage(

+ 1 - 0
mobile/lib/generated/intl/messages_fr.dart

@@ -1182,6 +1182,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "scanThisBarcodeWithnyourAuthenticatorApp":
             MessageLookupByLibrary.simpleMessage(
                 "Scannez ce code-barres avec\nvotre application d\'authentification"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "searchAlbumsEmptySection":
             MessageLookupByLibrary.simpleMessage("Albums"),
         "searchByAlbumNameHint":

+ 1 - 0
mobile/lib/generated/intl/messages_it.dart

@@ -1137,6 +1137,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "scanThisBarcodeWithnyourAuthenticatorApp":
             MessageLookupByLibrary.simpleMessage(
                 "Scansione questo codice QR\ncon la tua app di autenticazione"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "searchByAlbumNameHint":
             MessageLookupByLibrary.simpleMessage("Nome album"),
         "searchByExamples": MessageLookupByLibrary.simpleMessage(

+ 1 - 0
mobile/lib/generated/intl/messages_ko.dart

@@ -55,6 +55,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Modify your query, or try searching for"),
         "moveToHiddenAlbum":
             MessageLookupByLibrary.simpleMessage("Move to hidden album"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "selectALocation":
             MessageLookupByLibrary.simpleMessage("Select a location"),
         "selectALocationFirst":

+ 1 - 0
mobile/lib/generated/intl/messages_nl.dart

@@ -1206,6 +1206,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "scanThisBarcodeWithnyourAuthenticatorApp":
             MessageLookupByLibrary.simpleMessage(
                 "Scan deze barcode met\nje authenticator app"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "searchAlbumsEmptySection":
             MessageLookupByLibrary.simpleMessage("Albums"),
         "searchByAlbumNameHint":

+ 1 - 0
mobile/lib/generated/intl/messages_no.dart

@@ -77,6 +77,7 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Modify your query, or try searching for"),
         "moveToHiddenAlbum":
             MessageLookupByLibrary.simpleMessage("Move to hidden album"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "selectALocation":
             MessageLookupByLibrary.simpleMessage("Select a location"),
         "selectALocationFirst":

+ 1 - 0
mobile/lib/generated/intl/messages_pl.dart

@@ -171,6 +171,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "resetPasswordTitle":
             MessageLookupByLibrary.simpleMessage("Zresetuj hasło"),
         "saveKey": MessageLookupByLibrary.simpleMessage("Zapisz klucz"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "selectALocation":
             MessageLookupByLibrary.simpleMessage("Select a location"),
         "selectALocationFirst":

+ 1 - 0
mobile/lib/generated/intl/messages_pt.dart

@@ -1217,6 +1217,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "scanThisBarcodeWithnyourAuthenticatorApp":
             MessageLookupByLibrary.simpleMessage(
                 "Escaneie este código de barras com\nseu aplicativo autenticador"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "searchAlbumsEmptySection":
             MessageLookupByLibrary.simpleMessage("Álbuns"),
         "searchByAlbumNameHint":

+ 1 - 0
mobile/lib/generated/intl/messages_zh.dart

@@ -988,6 +988,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "scanCode": MessageLookupByLibrary.simpleMessage("扫描二维码/条码"),
         "scanThisBarcodeWithnyourAuthenticatorApp":
             MessageLookupByLibrary.simpleMessage("用您的身份验证器应用\n扫描此条码"),
+        "search": MessageLookupByLibrary.simpleMessage("Search"),
         "searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("相册"),
         "searchByAlbumNameHint": MessageLookupByLibrary.simpleMessage("相册名称"),
         "searchByExamples": MessageLookupByLibrary.simpleMessage(

+ 10 - 0
mobile/lib/generated/l10n.dart

@@ -8553,6 +8553,16 @@ class S {
       args: [],
     );
   }
+
+  /// `Search`
+  String get search {
+    return Intl.message(
+      'Search',
+      name: 'search',
+      desc: '',
+      args: [],
+    );
+  }
 }
 
 class AppLocalizationDelegate extends LocalizationsDelegate<S> {

+ 2 - 1
mobile/lib/l10n/intl_cs.arb

@@ -17,5 +17,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_de.arb

@@ -1203,5 +1203,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_en.arb

@@ -1211,5 +1211,6 @@
   "invalidEndpointMessage": "Sorry, the endpoint you entered is invalid. Please enter a valid endpoint and try again.",
   "endpointUpdatedMessage": "Endpoint updated successfully",
   "customEndpoint": "Connected to {endpoint}",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_es.arb

@@ -979,5 +979,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_fr.arb

@@ -1160,5 +1160,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_it.arb

@@ -1122,5 +1122,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_ko.arb

@@ -17,5 +17,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_nl.arb

@@ -1198,5 +1198,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_no.arb

@@ -31,5 +31,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_pl.arb

@@ -118,5 +118,6 @@
   "addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
   "addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
   "longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
-  "createCollaborativeLink": "Create collaborative link"
+  "createCollaborativeLink": "Create collaborative link",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_pt.arb

@@ -1211,5 +1211,6 @@
   "invalidEndpointMessage": "Desculpe, o endpoint que você inseriu é inválido. Por favor, insira um endpoint válido e tente novamente.",
   "endpointUpdatedMessage": "Endpoint atualizado com sucesso",
   "customEndpoint": "Conectado a {endpoint}",
-  "createCollaborativeLink": "Criar link colaborativo"
+  "createCollaborativeLink": "Criar link colaborativo",
+  "search": "Search"
 }

+ 2 - 1
mobile/lib/l10n/intl_zh.arb

@@ -1211,5 +1211,6 @@
   "invalidEndpointMessage": "抱歉,您输入的端点无效。请输入有效的端点,然后重试。",
   "endpointUpdatedMessage": "端点更新成功",
   "customEndpoint": "已连接至 {endpoint}",
-  "createCollaborativeLink": "创建协作链接"
+  "createCollaborativeLink": "创建协作链接",
+  "search": "Search"
 }

+ 13 - 3
mobile/lib/models/file/file.dart

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

+ 50 - 0
mobile/lib/services/home_widget_service.dart

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

+ 6 - 5
mobile/lib/services/machine_learning/semantic_search/frameworks/ml_framework.dart

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

+ 3 - 1
mobile/lib/services/sync_service.dart

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

+ 7 - 5
mobile/lib/ui/viewer/search/search_widget.dart

@@ -5,6 +5,7 @@ import "package:flutter/scheduler.dart";
 import "package:photos/core/event_bus.dart";
 import "package:photos/events/clear_and_unfocus_search_bar_event.dart";
 import "package:photos/events/tab_changed_event.dart";
+import "package:photos/generated/l10n.dart";
 import "package:photos/models/search/index_of_indexed_stack.dart";
 import "package:photos/models/search/search_result.dart";
 import "package:photos/services/search_service.dart";
@@ -130,17 +131,14 @@ class SearchWidgetState extends State<SearchWidget> {
                 color: colorScheme.backgroundBase,
                 child: Container(
                   color: colorScheme.fillFaint,
-                  child: TextFormField(
+                  child: TextField(
                     controller: textController,
                     focusNode: focusNode,
                     style: Theme.of(context).textTheme.titleMedium,
                     // Below parameters are to disable auto-suggestion
-                    enableSuggestions: false,
-                    autocorrect: false,
                     // Above parameters are to disable auto-suggestion
                     decoration: InputDecoration(
-                      //TODO: Extract string
-                      hintText: "Search",
+                      hintText: S.of(context).search,
                       filled: true,
                       fillColor: getEnteColorScheme(context).fillFaint,
                       border: const UnderlineInputBorder(
@@ -161,6 +159,9 @@ class SearchWidgetState extends State<SearchWidget> {
                         minHeight: 44,
                         minWidth: 44,
                       ),
+                      contentPadding: const EdgeInsets.symmetric(
+                        vertical: 8,
+                      ),
                       prefixIcon: Hero(
                         tag: "search_icon",
                         child: Icon(
@@ -168,6 +169,7 @@ class SearchWidgetState extends State<SearchWidget> {
                           color: colorScheme.strokeFaint,
                         ),
                       ),
+
                       /*Using valueListenableBuilder inside a stateful widget because this widget is only rebuild when
                       setState is called when deboucncing is over and the spinner needs to be shown while debouncing */
                       suffixIcon: ValueListenableBuilder(

+ 3 - 2
mobile/lib/utils/file_uploader.dart

@@ -363,9 +363,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();
     }

+ 6 - 54
mobile/pubspec.lock

@@ -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:
@@ -745,10 +705,10 @@ packages:
     dependency: "direct main"
     description:
       name: flutter_local_notifications
-      sha256: a701df4866f9a38bb8e4450a54c143bbeeb0ce2381e7df5a36e1006f3b43bb28
+      sha256: f9a05409385b77b06c18f200a41c7c2711ebf7415669350bb0f8474c07bd40d1
       url: "https://pub.dev"
     source: hosted
-    version: "17.0.1"
+    version: "17.0.0"
   flutter_local_notifications_linux:
     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:

+ 2 - 8
mobile/pubspec.yaml

@@ -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
@@ -175,7 +170,6 @@ dependencies:
   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

+ 5 - 2
server/configurations/local.yaml

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

+ 1 - 1
server/pkg/repo/public_collection.go

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

+ 18 - 49
web/apps/cast/src/components/PhotoAuditorium.tsx

@@ -1,50 +1,24 @@
-import { SlideshowContext } from "pages/slideshow";
-import { useContext, useEffect, useState } from "react";
+import { useEffect } from "react";
 
-export default function PhotoAuditorium({
-    url,
-    nextSlideUrl,
-}: {
+interface PhotoAuditoriumProps {
     url: string;
     nextSlideUrl: string;
-}) {
-    const { showNextSlide } = useContext(SlideshowContext);
-
-    const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false);
-    const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false);
-    const [prerenderTime, setPrerenderTime] = useState<number | null>(null);
-
+    showNextSlide: () => void;
+}
+export const PhotoAuditorium: React.FC<PhotoAuditoriumProps> = ({
+    url,
+    nextSlideUrl,
+    showNextSlide,
+}) => {
     useEffect(() => {
-        let timeout: NodeJS.Timeout;
-        let timeout2: NodeJS.Timeout;
-
-        if (nextSlidePrerendered) {
-            const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0;
-            const delayTime = Math.max(10000 - elapsedTime, 0);
-
-            if (elapsedTime >= 10000) {
-                setShowPreloadedNextSlide(true);
-            } else {
-                timeout = setTimeout(() => {
-                    setShowPreloadedNextSlide(true);
-                }, delayTime);
-            }
-
-            if (showNextSlide) {
-                timeout2 = setTimeout(() => {
-                    showNextSlide();
-                    setNextSlidePrerendered(false);
-                    setPrerenderTime(null);
-                    setShowPreloadedNextSlide(false);
-                }, delayTime);
-            }
-        }
+        const timeoutId = window.setTimeout(() => {
+            showNextSlide();
+        }, 10000);
 
         return () => {
-            if (timeout) clearTimeout(timeout);
-            if (timeout2) clearTimeout(timeout2);
+            if (timeoutId) clearTimeout(timeoutId);
         };
-    }, [nextSlidePrerendered, showNextSlide, prerenderTime]);
+    }, [showNextSlide]);
 
     return (
         <div
@@ -70,26 +44,21 @@ export default function PhotoAuditorium({
                 }}
             >
                 <img
-                    src={url}
+                    src={nextSlideUrl}
                     style={{
                         maxWidth: "100%",
                         maxHeight: "100%",
-                        display: showPreloadedNextSlide ? "none" : "block",
+                        display: "none",
                     }}
                 />
                 <img
-                    src={nextSlideUrl}
+                    src={url}
                     style={{
                         maxWidth: "100%",
                         maxHeight: "100%",
-                        display: showPreloadedNextSlide ? "block" : "none",
-                    }}
-                    onLoad={() => {
-                        setNextSlidePrerendered(true);
-                        setPrerenderTime(Date.now());
                     }}
                 />
             </div>
         </div>
     );
-}
+};

+ 0 - 95
web/apps/cast/src/components/Theatre/PhotoAuditorium.tsx

@@ -1,95 +0,0 @@
-import { SlideshowContext } from "pages/slideshow";
-import { useContext, useEffect, useState } from "react";
-
-export default function PhotoAuditorium({
-    url,
-    nextSlideUrl,
-}: {
-    url: string;
-    nextSlideUrl: string;
-}) {
-    const { showNextSlide } = useContext(SlideshowContext);
-
-    const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false);
-    const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false);
-    const [prerenderTime, setPrerenderTime] = useState<number | null>(null);
-
-    useEffect(() => {
-        let timeout: NodeJS.Timeout;
-        let timeout2: NodeJS.Timeout;
-
-        if (nextSlidePrerendered) {
-            const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0;
-            const delayTime = Math.max(10000 - elapsedTime, 0);
-
-            if (elapsedTime >= 10000) {
-                setShowPreloadedNextSlide(true);
-            } else {
-                timeout = setTimeout(() => {
-                    setShowPreloadedNextSlide(true);
-                }, delayTime);
-            }
-
-            if (showNextSlide) {
-                timeout2 = setTimeout(() => {
-                    showNextSlide();
-                    setNextSlidePrerendered(false);
-                    setPrerenderTime(null);
-                    setShowPreloadedNextSlide(false);
-                }, delayTime);
-            }
-        }
-
-        return () => {
-            if (timeout) clearTimeout(timeout);
-            if (timeout2) clearTimeout(timeout2);
-        };
-    }, [nextSlidePrerendered, showNextSlide, prerenderTime]);
-
-    return (
-        <div
-            style={{
-                width: "100vw",
-                height: "100vh",
-                backgroundImage: `url(${url})`,
-                backgroundSize: "cover",
-                backgroundPosition: "center",
-                backgroundRepeat: "no-repeat",
-                backgroundBlendMode: "multiply",
-                backgroundColor: "rgba(0, 0, 0, 0.5)",
-            }}
-        >
-            <div
-                style={{
-                    height: "100%",
-                    width: "100%",
-                    display: "flex",
-                    justifyContent: "center",
-                    alignItems: "center",
-                    backdropFilter: "blur(10px)",
-                }}
-            >
-                <img
-                    src={url}
-                    style={{
-                        maxWidth: "100%",
-                        maxHeight: "100%",
-                        display: showPreloadedNextSlide ? "none" : "block",
-                    }}
-                />
-                <img
-                    src={nextSlideUrl}
-                    style={{
-                        maxWidth: "100%",
-                        maxHeight: "100%",
-                        display: showPreloadedNextSlide ? "block" : "none",
-                    }}
-                    onLoad={() => {
-                        setNextSlidePrerendered(true);
-                        setPrerenderTime(Date.now());
-                    }}
-                />
-            </div>
-        </div>
-    );
-}

+ 0 - 55
web/apps/cast/src/components/Theatre/VideoAuditorium.tsx

@@ -1,55 +0,0 @@
-import mime from "mime-types";
-import { SlideshowContext } from "pages/slideshow";
-import { useContext, useEffect, useRef } from "react";
-
-export default function VideoAuditorium({
-    name,
-    url,
-}: {
-    name: string;
-    url: string;
-}) {
-    const { showNextSlide } = useContext(SlideshowContext);
-
-    const videoRef = useRef<HTMLVideoElement>(null);
-
-    useEffect(() => {
-        attemptPlay();
-    }, [url, videoRef]);
-
-    const attemptPlay = async () => {
-        if (videoRef.current) {
-            try {
-                await videoRef.current.play();
-            } catch {
-                showNextSlide();
-            }
-        }
-    };
-
-    return (
-        <div
-            style={{
-                width: "100vw",
-                height: "100vh",
-                display: "flex",
-                justifyContent: "center",
-                alignItems: "center",
-            }}
-        >
-            <video
-                ref={videoRef}
-                autoPlay
-                controls
-                style={{
-                    maxWidth: "100vw",
-                    maxHeight: "100vh",
-                }}
-                onError={showNextSlide}
-                onEnded={showNextSlide}
-            >
-                <source src={url} type={mime.lookup(name)} />
-            </video>
-        </div>
-    );
-}

+ 0 - 30
web/apps/cast/src/components/Theatre/index.tsx

@@ -1,30 +0,0 @@
-import { FILE_TYPE } from "constants/file";
-import PhotoAuditorium from "./PhotoAuditorium";
-// import VideoAuditorium from './VideoAuditorium';
-
-interface fileProp {
-    fileName: string;
-    fileURL: string;
-    type: FILE_TYPE;
-}
-
-interface IProps {
-    file1: fileProp;
-    file2: fileProp;
-}
-
-export default function Theatre(props: IProps) {
-    switch (props.file1.type && props.file2.type) {
-        case FILE_TYPE.IMAGE:
-            return (
-                <PhotoAuditorium
-                    url={props.file1.fileURL}
-                    nextSlideUrl={props.file2.fileURL}
-                />
-            );
-        // case FILE_TYPE.VIDEO:
-        //     return (
-        //         <VideoAuditorium name={props.fileName} url={props.fileURL} />
-        //     );
-    }
-}

+ 46 - 83
web/apps/cast/src/pages/slideshow.tsx

@@ -1,9 +1,9 @@
 import log from "@/next/log";
 import PairedSuccessfullyOverlay from "components/PairedSuccessfullyOverlay";
-import Theatre from "components/Theatre";
+import { PhotoAuditorium } from "components/PhotoAuditorium";
 import { FILE_TYPE } from "constants/file";
 import { useRouter } from "next/router";
-import { createContext, useEffect, useState } from "react";
+import { useEffect, useState } from "react";
 import {
     getCastCollection,
     getLocalFiles,
@@ -13,25 +13,20 @@ import { Collection } from "types/collection";
 import { EnteFile } from "types/file";
 import { getPreviewableImage, isRawFileFromFileName } from "utils/file";
 
-export const SlideshowContext = createContext<{
-    showNextSlide: () => void;
-}>(null);
-
 const renderableFileURLCache = new Map<number, string>();
 
 export default function Slideshow() {
-    const [collectionFiles, setCollectionFiles] = useState<EnteFile[]>([]);
-
-    const [currentFile, setCurrentFile] = useState<EnteFile | undefined>(
-        undefined,
-    );
-    const [nextFile, setNextFile] = useState<EnteFile | undefined>(undefined);
-
     const [loading, setLoading] = useState(true);
     const [castToken, setCastToken] = useState<string>("");
     const [castCollection, setCastCollection] = useState<
         Collection | undefined
-    >(undefined);
+    >();
+    const [collectionFiles, setCollectionFiles] = useState<EnteFile[]>([]);
+    const [currentFileId, setCurrentFileId] = useState<number | undefined>();
+    const [currentFileURL, setCurrentFileURL] = useState<string | undefined>();
+    const [nextFileURL, setNextFileURL] = useState<string | undefined>();
+
+    const router = useRouter();
 
     const syncCastFiles = async (token: string) => {
         try {
@@ -72,29 +67,16 @@ export default function Slideshow() {
 
     const isFileEligibleForCast = (file: EnteFile) => {
         const fileType = file.metadata.fileType;
-        if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) {
+        if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO)
             return false;
-        }
 
-        const fileSizeLimit = 100 * 1024 * 1024;
+        if (file.info.fileSize > 100 * 1024 * 1024) return false;
 
-        if (file.info.fileSize > fileSizeLimit) {
-            return false;
-        }
-
-        const name = file.metadata.title;
-
-        if (fileType === FILE_TYPE.IMAGE) {
-            if (isRawFileFromFileName(name)) {
-                return false;
-            }
-        }
+        if (isRawFileFromFileName(file.metadata.title)) return false;
 
         return true;
     };
 
-    const router = useRouter();
-
     useEffect(() => {
         try {
             const castToken = window.localStorage.getItem("castToken");
@@ -117,9 +99,9 @@ export default function Slideshow() {
         showNextSlide();
     }, [collectionFiles]);
 
-    const showNextSlide = () => {
+    const showNextSlide = async () => {
         const currentIndex = collectionFiles.findIndex(
-            (file) => file.id === currentFile?.id,
+            (file) => file.id === currentFileId,
         );
 
         const nextIndex = (currentIndex + 1) % collectionFiles.length;
@@ -128,63 +110,44 @@ export default function Slideshow() {
         const nextFile = collectionFiles[nextIndex];
         const nextNextFile = collectionFiles[nextNextIndex];
 
-        setCurrentFile(nextFile);
-        setNextFile(nextNextFile);
-    };
-
-    const [renderableFileURL, setRenderableFileURL] = useState<string>("");
-
-    const getRenderableFileURL = async () => {
-        if (!currentFile) return;
-
-        const cacheValue = renderableFileURLCache.get(currentFile.id);
-        if (cacheValue) {
-            setRenderableFileURL(cacheValue);
-            setLoading(false);
-            return;
+        let nextURL = renderableFileURLCache.get(nextFile.id);
+        let nextNextURL = renderableFileURLCache.get(nextNextFile.id);
+
+        if (!nextURL) {
+            try {
+                const blob = await getPreviewableImage(nextFile, castToken);
+                const url = URL.createObjectURL(blob);
+                renderableFileURLCache.set(nextFile.id, url);
+                nextURL = url;
+            } catch (e) {
+                return;
+            }
         }
 
-        try {
-            const blob = await getPreviewableImage(
-                currentFile as EnteFile,
-                castToken,
-            );
-
-            const url = URL.createObjectURL(blob);
-
-            renderableFileURLCache.set(currentFile?.id, url);
-
-            setRenderableFileURL(url);
-        } catch (e) {
-            return;
-        } finally {
-            setLoading(false);
+        if (!nextNextURL) {
+            try {
+                const blob = await getPreviewableImage(nextNextFile, castToken);
+                const url = URL.createObjectURL(blob);
+                renderableFileURLCache.set(nextNextFile.id, url);
+                nextNextURL = url;
+            } catch (e) {
+                return;
+            }
         }
+
+        setLoading(false);
+        setCurrentFileId(nextFile.id);
+        setCurrentFileURL(nextURL);
+        setNextFileURL(nextNextURL);
     };
 
-    useEffect(() => {
-        if (currentFile) {
-            getRenderableFileURL();
-        }
-    }, [currentFile]);
+    if (loading) return <PairedSuccessfullyOverlay />;
 
     return (
-        <>
-            <SlideshowContext.Provider value={{ showNextSlide }}>
-                <Theatre
-                    file1={{
-                        fileName: currentFile?.metadata.title,
-                        fileURL: renderableFileURL,
-                        type: currentFile?.metadata.fileType,
-                    }}
-                    file2={{
-                        fileName: nextFile?.metadata.title,
-                        fileURL: renderableFileURL,
-                        type: nextFile?.metadata.fileType,
-                    }}
-                />
-            </SlideshowContext.Provider>
-            {loading && <PairedSuccessfullyOverlay />}
-        </>
+        <PhotoAuditorium
+            url={currentFileURL}
+            nextSlideUrl={nextFileURL}
+            showNextSlide={showNextSlide}
+        />
     );
 }

+ 1 - 1
web/apps/photos/src/components/Collections/CollectionOptions/AlbumCastDialog.tsx

@@ -50,7 +50,7 @@ export default function AlbumCastDialog(props: Props) {
         setFieldError,
     ) => {
         try {
-            await doCast(value);
+            await doCast(value.trim());
             props.onHide();
         } catch (e) {
             const error = e as Error;

+ 2 - 1
web/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx

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

+ 1 - 1
web/apps/photos/src/components/Sidebar/UtilitySection.tsx

@@ -24,7 +24,7 @@ import {
 import { getAccountsURL } from "@ente/shared/network/api";
 import { THEME_COLOR } from "@ente/shared/themes/constants";
 import { EnteMenuItem } from "components/Menu/EnteMenuItem";
-import WatchFolder from "components/WatchFolder";
+import { WatchFolder } from "components/WatchFolder";
 import isElectron from "is-electron";
 import { getAccountsToken } from "services/userService";
 import { getDownloadAppMessage } from "utils/ui";

+ 1 - 1
web/apps/photos/src/components/Upload/Uploader.tsx

@@ -24,7 +24,7 @@ import {
     savePublicCollectionUploaderName,
 } from "services/publicCollectionService";
 import uploadManager from "services/upload/uploadManager";
-import watchFolderService from "services/watchFolder/watchFolderService";
+import watchFolderService from "services/watch";
 import { NotificationAttributes } from "types/Notification";
 import { Collection } from "types/collection";
 import {

+ 364 - 0
web/apps/photos/src/components/WatchFolder.tsx

@@ -0,0 +1,364 @@
+import {
+    FlexWrapper,
+    HorizontalFlex,
+    SpaceBetweenFlex,
+    VerticallyCentered,
+} from "@ente/shared/components/Container";
+import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleWithCloseButton";
+import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
+import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
+import CheckIcon from "@mui/icons-material/Check";
+import DoNotDisturbOutlinedIcon from "@mui/icons-material/DoNotDisturbOutlined";
+import FolderCopyOutlinedIcon from "@mui/icons-material/FolderCopyOutlined";
+import FolderOpenIcon from "@mui/icons-material/FolderOpen";
+import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
+import {
+    Box,
+    Button,
+    CircularProgress,
+    Dialog,
+    DialogContent,
+    Stack,
+    Tooltip,
+    Typography,
+} from "@mui/material";
+import { styled } from "@mui/material/styles";
+import UploadStrategyChoiceModal from "components/Upload/UploadStrategyChoiceModal";
+import { PICKED_UPLOAD_TYPE, UPLOAD_STRATEGY } from "constants/upload";
+import { t } from "i18next";
+import { AppContext } from "pages/_app";
+import React, { useContext, useEffect, useState } from "react";
+import watchFolderService from "services/watch";
+import { WatchMapping } from "types/watchFolder";
+import { getImportSuggestion } from "utils/upload";
+
+interface WatchFolderProps {
+    open: boolean;
+    onClose: () => void;
+}
+
+export const WatchFolder: React.FC<WatchFolderProps> = ({ open, onClose }) => {
+    const [mappings, setMappings] = useState<WatchMapping[]>([]);
+    const [inputFolderPath, setInputFolderPath] = useState("");
+    const [choiceModalOpen, setChoiceModalOpen] = useState(false);
+    const appContext = useContext(AppContext);
+
+    const electron = globalThis.electron;
+
+    useEffect(() => {
+        if (!electron) return;
+        watchFolderService.getWatchMappings().then((m) => setMappings(m));
+    }, []);
+
+    useEffect(() => {
+        if (
+            appContext.watchFolderFiles &&
+            appContext.watchFolderFiles.length > 0
+        ) {
+            handleFolderDrop(appContext.watchFolderFiles);
+            appContext.setWatchFolderFiles(null);
+        }
+    }, [appContext.watchFolderFiles]);
+
+    const handleFolderDrop = async (folders: FileList) => {
+        for (let i = 0; i < folders.length; i++) {
+            const folder: any = folders[i];
+            const path = (folder.path as string).replace(/\\/g, "/");
+            if (await watchFolderService.isFolder(path)) {
+                await addFolderForWatching(path);
+            }
+        }
+    };
+
+    const addFolderForWatching = async (path: string) => {
+        if (!electron) return;
+
+        setInputFolderPath(path);
+        const files = await electron.getDirFiles(path);
+        const analysisResult = getImportSuggestion(
+            PICKED_UPLOAD_TYPE.FOLDERS,
+            files,
+        );
+        if (analysisResult.hasNestedFolders) {
+            setChoiceModalOpen(true);
+        } else {
+            handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION, path);
+        }
+    };
+
+    const handleAddFolderClick = async () => {
+        await handleFolderSelection();
+    };
+
+    const handleFolderSelection = async () => {
+        const folderPath = await watchFolderService.selectFolder();
+        if (folderPath) {
+            await addFolderForWatching(folderPath);
+        }
+    };
+
+    const handleAddWatchMapping = async (
+        uploadStrategy: UPLOAD_STRATEGY,
+        folderPath?: string,
+    ) => {
+        folderPath = folderPath || inputFolderPath;
+        await watchFolderService.addWatchMapping(
+            folderPath.substring(folderPath.lastIndexOf("/") + 1),
+            folderPath,
+            uploadStrategy,
+        );
+        setInputFolderPath("");
+        setMappings(await watchFolderService.getWatchMappings());
+    };
+
+    const handleRemoveWatchMapping = (mapping: WatchMapping) => {
+        watchFolderService
+            .mappingsAfterRemovingFolder(mapping.folderPath)
+            .then((ms) => setMappings(ms));
+    };
+
+    const closeChoiceModal = () => setChoiceModalOpen(false);
+
+    const uploadToSingleCollection = () => {
+        closeChoiceModal();
+        handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION);
+    };
+
+    const uploadToMultipleCollection = () => {
+        closeChoiceModal();
+        handleAddWatchMapping(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER);
+    };
+
+    return (
+        <>
+            <Dialog
+                open={open}
+                onClose={onClose}
+                PaperProps={{ sx: { height: "448px", maxWidth: "414px" } }}
+            >
+                <DialogTitleWithCloseButton
+                    onClose={onClose}
+                    sx={{ "&&&": { padding: "32px 16px 16px 24px" } }}
+                >
+                    {t("WATCHED_FOLDERS")}
+                </DialogTitleWithCloseButton>
+                <DialogContent sx={{ flex: 1 }}>
+                    <Stack spacing={1} p={1.5} height={"100%"}>
+                        <MappingList
+                            mappings={mappings}
+                            handleRemoveWatchMapping={handleRemoveWatchMapping}
+                        />
+                        <Button
+                            fullWidth
+                            color="accent"
+                            onClick={handleAddFolderClick}
+                        >
+                            <span>+</span>
+                            <span
+                                style={{
+                                    marginLeft: "8px",
+                                }}
+                            ></span>
+                            {t("ADD_FOLDER")}
+                        </Button>
+                    </Stack>
+                </DialogContent>
+            </Dialog>
+            <UploadStrategyChoiceModal
+                open={choiceModalOpen}
+                onClose={closeChoiceModal}
+                uploadToSingleCollection={uploadToSingleCollection}
+                uploadToMultipleCollection={uploadToMultipleCollection}
+            />
+        </>
+    );
+};
+
+const MappingsContainer = styled(Box)(() => ({
+    height: "278px",
+    overflow: "auto",
+    "&::-webkit-scrollbar": {
+        width: "4px",
+    },
+}));
+
+const NoMappingsContainer = styled(VerticallyCentered)({
+    textAlign: "left",
+    alignItems: "flex-start",
+    marginBottom: "32px",
+});
+
+const EntryContainer = styled(Box)({
+    marginLeft: "12px",
+    marginRight: "6px",
+    marginBottom: "12px",
+});
+
+interface MappingListProps {
+    mappings: WatchMapping[];
+    handleRemoveWatchMapping: (value: WatchMapping) => void;
+}
+
+const MappingList: React.FC<MappingListProps> = ({
+    mappings,
+    handleRemoveWatchMapping,
+}) => {
+    return mappings.length === 0 ? (
+        <NoMappingsContent />
+    ) : (
+        <MappingsContainer>
+            {mappings.map((mapping) => {
+                return (
+                    <MappingEntry
+                        key={mapping.rootFolderName}
+                        mapping={mapping}
+                        handleRemoveMapping={handleRemoveWatchMapping}
+                    />
+                );
+            })}
+        </MappingsContainer>
+    );
+};
+
+const NoMappingsContent: React.FC = () => {
+    return (
+        <NoMappingsContainer>
+            <Stack spacing={1}>
+                <Typography variant="large" fontWeight={"bold"}>
+                    {t("NO_FOLDERS_ADDED")}
+                </Typography>
+                <Typography py={0.5} variant={"small"} color="text.muted">
+                    {t("FOLDERS_AUTOMATICALLY_MONITORED")}
+                </Typography>
+                <Typography variant={"small"} color="text.muted">
+                    <FlexWrapper gap={1}>
+                        <CheckmarkIcon />
+                        {t("UPLOAD_NEW_FILES_TO_ENTE")}
+                    </FlexWrapper>
+                </Typography>
+                <Typography variant={"small"} color="text.muted">
+                    <FlexWrapper gap={1}>
+                        <CheckmarkIcon />
+                        {t("REMOVE_DELETED_FILES_FROM_ENTE")}
+                    </FlexWrapper>
+                </Typography>
+            </Stack>
+        </NoMappingsContainer>
+    );
+};
+
+const CheckmarkIcon: React.FC = () => {
+    return (
+        <CheckIcon
+            fontSize="small"
+            sx={{
+                display: "inline",
+                fontSize: "15px",
+
+                color: (theme) => theme.palette.secondary.main,
+            }}
+        />
+    );
+};
+
+interface MappingEntryProps {
+    mapping: WatchMapping;
+    handleRemoveMapping: (mapping: WatchMapping) => void;
+}
+
+const MappingEntry: React.FC<MappingEntryProps> = ({
+    mapping,
+    handleRemoveMapping,
+}) => {
+    const appContext = React.useContext(AppContext);
+
+    const stopWatching = () => {
+        handleRemoveMapping(mapping);
+    };
+
+    const confirmStopWatching = () => {
+        appContext.setDialogMessage({
+            title: t("STOP_WATCHING_FOLDER"),
+            content: t("STOP_WATCHING_DIALOG_MESSAGE"),
+            close: {
+                text: t("CANCEL"),
+                variant: "secondary",
+            },
+            proceed: {
+                action: stopWatching,
+                text: t("YES_STOP"),
+                variant: "critical",
+            },
+        });
+    };
+
+    return (
+        <SpaceBetweenFlex>
+            <HorizontalFlex>
+                {mapping &&
+                mapping.uploadStrategy === UPLOAD_STRATEGY.SINGLE_COLLECTION ? (
+                    <Tooltip title={t("UPLOADED_TO_SINGLE_COLLECTION")}>
+                        <FolderOpenIcon />
+                    </Tooltip>
+                ) : (
+                    <Tooltip title={t("UPLOADED_TO_SEPARATE_COLLECTIONS")}>
+                        <FolderCopyOutlinedIcon />
+                    </Tooltip>
+                )}
+                <EntryContainer>
+                    <EntryHeading mapping={mapping} />
+                    <Typography color="text.muted" variant="small">
+                        {mapping.folderPath}
+                    </Typography>
+                </EntryContainer>
+            </HorizontalFlex>
+            <MappingEntryOptions confirmStopWatching={confirmStopWatching} />
+        </SpaceBetweenFlex>
+    );
+};
+
+interface EntryHeadingProps {
+    mapping: WatchMapping;
+}
+
+const EntryHeading: React.FC<EntryHeadingProps> = ({ mapping }) => {
+    const appContext = useContext(AppContext);
+    return (
+        <FlexWrapper gap={1}>
+            <Typography>{mapping.rootFolderName}</Typography>
+            {appContext.isFolderSyncRunning &&
+                watchFolderService.isMappingSyncInProgress(mapping) && (
+                    <CircularProgress size={12} />
+                )}
+        </FlexWrapper>
+    );
+};
+
+interface MappingEntryOptionsProps {
+    confirmStopWatching: () => void;
+}
+
+const MappingEntryOptions: React.FC<MappingEntryOptionsProps> = ({
+    confirmStopWatching,
+}) => {
+    return (
+        <OverflowMenu
+            menuPaperProps={{
+                sx: {
+                    backgroundColor: (theme) =>
+                        theme.colors.background.elevated2,
+                },
+            }}
+            ariaControls={"watch-mapping-option"}
+            triggerButtonIcon={<MoreHorizIcon />}
+        >
+            <OverflowMenuOption
+                color="critical"
+                onClick={confirmStopWatching}
+                startIcon={<DoNotDisturbOutlinedIcon />}
+            >
+                {t("STOP_WATCHING")}
+            </OverflowMenuOption>
+        </OverflowMenu>
+    );
+};

+ 0 - 152
web/apps/photos/src/components/WatchFolder/index.tsx

@@ -1,152 +0,0 @@
-import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleWithCloseButton";
-import { Button, Dialog, DialogContent, Stack } from "@mui/material";
-import UploadStrategyChoiceModal from "components/Upload/UploadStrategyChoiceModal";
-import { PICKED_UPLOAD_TYPE, UPLOAD_STRATEGY } from "constants/upload";
-import { t } from "i18next";
-import { AppContext } from "pages/_app";
-import { useContext, useEffect, useState } from "react";
-import watchFolderService from "services/watchFolder/watchFolderService";
-import { WatchMapping } from "types/watchFolder";
-import { getImportSuggestion } from "utils/upload";
-import { MappingList } from "./mappingList";
-
-interface Iprops {
-    open: boolean;
-    onClose: () => void;
-}
-
-export default function WatchFolder({ open, onClose }: Iprops) {
-    const [mappings, setMappings] = useState<WatchMapping[]>([]);
-    const [inputFolderPath, setInputFolderPath] = useState("");
-    const [choiceModalOpen, setChoiceModalOpen] = useState(false);
-    const appContext = useContext(AppContext);
-
-    const electron = globalThis.electron;
-
-    useEffect(() => {
-        if (!electron) return;
-        watchFolderService.getWatchMappings().then((m) => setMappings(m));
-    }, []);
-
-    useEffect(() => {
-        if (
-            appContext.watchFolderFiles &&
-            appContext.watchFolderFiles.length > 0
-        ) {
-            handleFolderDrop(appContext.watchFolderFiles);
-            appContext.setWatchFolderFiles(null);
-        }
-    }, [appContext.watchFolderFiles]);
-
-    const handleFolderDrop = async (folders: FileList) => {
-        for (let i = 0; i < folders.length; i++) {
-            const folder: any = folders[i];
-            const path = (folder.path as string).replace(/\\/g, "/");
-            if (await watchFolderService.isFolder(path)) {
-                await addFolderForWatching(path);
-            }
-        }
-    };
-
-    const addFolderForWatching = async (path: string) => {
-        if (!electron) return;
-
-        setInputFolderPath(path);
-        const files = await electron.getDirFiles(path);
-        const analysisResult = getImportSuggestion(
-            PICKED_UPLOAD_TYPE.FOLDERS,
-            files,
-        );
-        if (analysisResult.hasNestedFolders) {
-            setChoiceModalOpen(true);
-        } else {
-            handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION, path);
-        }
-    };
-
-    const handleAddFolderClick = async () => {
-        await handleFolderSelection();
-    };
-
-    const handleFolderSelection = async () => {
-        const folderPath = await watchFolderService.selectFolder();
-        if (folderPath) {
-            await addFolderForWatching(folderPath);
-        }
-    };
-
-    const handleAddWatchMapping = async (
-        uploadStrategy: UPLOAD_STRATEGY,
-        folderPath?: string,
-    ) => {
-        folderPath = folderPath || inputFolderPath;
-        await watchFolderService.addWatchMapping(
-            folderPath.substring(folderPath.lastIndexOf("/") + 1),
-            folderPath,
-            uploadStrategy,
-        );
-        setInputFolderPath("");
-        setMappings(await watchFolderService.getWatchMappings());
-    };
-
-    const handleRemoveWatchMapping = async (mapping: WatchMapping) => {
-        await watchFolderService.removeWatchMapping(mapping.folderPath);
-        setMappings(await watchFolderService.getWatchMappings());
-    };
-
-    const closeChoiceModal = () => setChoiceModalOpen(false);
-
-    const uploadToSingleCollection = () => {
-        closeChoiceModal();
-        handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION);
-    };
-
-    const uploadToMultipleCollection = () => {
-        closeChoiceModal();
-        handleAddWatchMapping(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER);
-    };
-
-    return (
-        <>
-            <Dialog
-                open={open}
-                onClose={onClose}
-                PaperProps={{ sx: { height: "448px", maxWidth: "414px" } }}
-            >
-                <DialogTitleWithCloseButton
-                    onClose={onClose}
-                    sx={{ "&&&": { padding: "32px 16px 16px 24px" } }}
-                >
-                    {t("WATCHED_FOLDERS")}
-                </DialogTitleWithCloseButton>
-                <DialogContent sx={{ flex: 1 }}>
-                    <Stack spacing={1} p={1.5} height={"100%"}>
-                        <MappingList
-                            mappings={mappings}
-                            handleRemoveWatchMapping={handleRemoveWatchMapping}
-                        />
-                        <Button
-                            fullWidth
-                            color="accent"
-                            onClick={handleAddFolderClick}
-                        >
-                            <span>+</span>
-                            <span
-                                style={{
-                                    marginLeft: "8px",
-                                }}
-                            ></span>
-                            {t("ADD_FOLDER")}
-                        </Button>
-                    </Stack>
-                </DialogContent>
-            </Dialog>
-            <UploadStrategyChoiceModal
-                open={choiceModalOpen}
-                onClose={closeChoiceModal}
-                uploadToSingleCollection={uploadToSingleCollection}
-                uploadToMultipleCollection={uploadToMultipleCollection}
-            />
-        </>
-    );
-}

+ 0 - 23
web/apps/photos/src/components/WatchFolder/mappingEntry/entryHeading.tsx

@@ -1,23 +0,0 @@
-import { FlexWrapper } from "@ente/shared/components/Container";
-import { CircularProgress, Typography } from "@mui/material";
-import { AppContext } from "pages/_app";
-import { useContext } from "react";
-import watchFolderService from "services/watchFolder/watchFolderService";
-import { WatchMapping } from "types/watchFolder";
-
-interface Iprops {
-    mapping: WatchMapping;
-}
-
-export function EntryHeading({ mapping }: Iprops) {
-    const appContext = useContext(AppContext);
-    return (
-        <FlexWrapper gap={1}>
-            <Typography>{mapping.rootFolderName}</Typography>
-            {appContext.isFolderSyncRunning &&
-                watchFolderService.isMappingSyncInProgress(mapping) && (
-                    <CircularProgress size={12} />
-                )}
-        </FlexWrapper>
-    );
-}

+ 0 - 69
web/apps/photos/src/components/WatchFolder/mappingEntry/index.tsx

@@ -1,69 +0,0 @@
-import {
-    HorizontalFlex,
-    SpaceBetweenFlex,
-} from "@ente/shared/components/Container";
-import FolderCopyOutlinedIcon from "@mui/icons-material/FolderCopyOutlined";
-import FolderOpenIcon from "@mui/icons-material/FolderOpen";
-import { Tooltip, Typography } from "@mui/material";
-import { t } from "i18next";
-import { AppContext } from "pages/_app";
-import React from "react";
-import { WatchMapping } from "types/watchFolder";
-import { EntryContainer } from "../styledComponents";
-
-import { UPLOAD_STRATEGY } from "constants/upload";
-import { EntryHeading } from "./entryHeading";
-import MappingEntryOptions from "./mappingEntryOptions";
-
-interface Iprops {
-    mapping: WatchMapping;
-    handleRemoveMapping: (mapping: WatchMapping) => void;
-}
-
-export function MappingEntry({ mapping, handleRemoveMapping }: Iprops) {
-    const appContext = React.useContext(AppContext);
-
-    const stopWatching = () => {
-        handleRemoveMapping(mapping);
-    };
-
-    const confirmStopWatching = () => {
-        appContext.setDialogMessage({
-            title: t("STOP_WATCHING_FOLDER"),
-            content: t("STOP_WATCHING_DIALOG_MESSAGE"),
-            close: {
-                text: t("CANCEL"),
-                variant: "secondary",
-            },
-            proceed: {
-                action: stopWatching,
-                text: t("YES_STOP"),
-                variant: "critical",
-            },
-        });
-    };
-
-    return (
-        <SpaceBetweenFlex>
-            <HorizontalFlex>
-                {mapping &&
-                mapping.uploadStrategy === UPLOAD_STRATEGY.SINGLE_COLLECTION ? (
-                    <Tooltip title={t("UPLOADED_TO_SINGLE_COLLECTION")}>
-                        <FolderOpenIcon />
-                    </Tooltip>
-                ) : (
-                    <Tooltip title={t("UPLOADED_TO_SEPARATE_COLLECTIONS")}>
-                        <FolderCopyOutlinedIcon />
-                    </Tooltip>
-                )}
-                <EntryContainer>
-                    <EntryHeading mapping={mapping} />
-                    <Typography color="text.muted" variant="small">
-                        {mapping.folderPath}
-                    </Typography>
-                </EntryContainer>
-            </HorizontalFlex>
-            <MappingEntryOptions confirmStopWatching={confirmStopWatching} />
-        </SpaceBetweenFlex>
-    );
-}

+ 0 - 33
web/apps/photos/src/components/WatchFolder/mappingEntry/mappingEntryOptions.tsx

@@ -1,33 +0,0 @@
-import { t } from "i18next";
-
-import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
-import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
-import DoNotDisturbOutlinedIcon from "@mui/icons-material/DoNotDisturbOutlined";
-import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
-
-interface Iprops {
-    confirmStopWatching: () => void;
-}
-
-export default function MappingEntryOptions({ confirmStopWatching }: Iprops) {
-    return (
-        <OverflowMenu
-            menuPaperProps={{
-                sx: {
-                    backgroundColor: (theme) =>
-                        theme.colors.background.elevated2,
-                },
-            }}
-            ariaControls={"watch-mapping-option"}
-            triggerButtonIcon={<MoreHorizIcon />}
-        >
-            <OverflowMenuOption
-                color="critical"
-                onClick={confirmStopWatching}
-                startIcon={<DoNotDisturbOutlinedIcon />}
-            >
-                {t("STOP_WATCHING")}
-            </OverflowMenuOption>
-        </OverflowMenu>
-    );
-}

+ 0 - 26
web/apps/photos/src/components/WatchFolder/mappingList/index.tsx

@@ -1,26 +0,0 @@
-import { WatchMapping } from "types/watchFolder";
-import { MappingEntry } from "../mappingEntry";
-import { MappingsContainer } from "../styledComponents";
-import { NoMappingsContent } from "./noMappingsContent/noMappingsContent";
-interface Iprops {
-    mappings: WatchMapping[];
-    handleRemoveWatchMapping: (value: WatchMapping) => void;
-}
-
-export function MappingList({ mappings, handleRemoveWatchMapping }: Iprops) {
-    return mappings.length === 0 ? (
-        <NoMappingsContent />
-    ) : (
-        <MappingsContainer>
-            {mappings.map((mapping) => {
-                return (
-                    <MappingEntry
-                        key={mapping.rootFolderName}
-                        mapping={mapping}
-                        handleRemoveMapping={handleRemoveWatchMapping}
-                    />
-                );
-            })}
-        </MappingsContainer>
-    );
-}

+ 0 - 15
web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/checkmarkIcon.tsx

@@ -1,15 +0,0 @@
-import CheckIcon from "@mui/icons-material/Check";
-
-export function CheckmarkIcon() {
-    return (
-        <CheckIcon
-            fontSize="small"
-            sx={{
-                display: "inline",
-                fontSize: "15px",
-
-                color: (theme) => theme.palette.secondary.main,
-            }}
-        />
-    );
-}

+ 0 - 33
web/apps/photos/src/components/WatchFolder/mappingList/noMappingsContent/noMappingsContent.tsx

@@ -1,33 +0,0 @@
-import { Stack, Typography } from "@mui/material";
-import { t } from "i18next";
-
-import { FlexWrapper } from "@ente/shared/components/Container";
-import { NoMappingsContainer } from "../../styledComponents";
-import { CheckmarkIcon } from "./checkmarkIcon";
-
-export function NoMappingsContent() {
-    return (
-        <NoMappingsContainer>
-            <Stack spacing={1}>
-                <Typography variant="large" fontWeight={"bold"}>
-                    {t("NO_FOLDERS_ADDED")}
-                </Typography>
-                <Typography py={0.5} variant={"small"} color="text.muted">
-                    {t("FOLDERS_AUTOMATICALLY_MONITORED")}
-                </Typography>
-                <Typography variant={"small"} color="text.muted">
-                    <FlexWrapper gap={1}>
-                        <CheckmarkIcon />
-                        {t("UPLOAD_NEW_FILES_TO_ENTE")}
-                    </FlexWrapper>
-                </Typography>
-                <Typography variant={"small"} color="text.muted">
-                    <FlexWrapper gap={1}>
-                        <CheckmarkIcon />
-                        {t("REMOVE_DELETED_FILES_FROM_ENTE")}
-                    </FlexWrapper>
-                </Typography>
-            </Stack>
-        </NoMappingsContainer>
-    );
-}

+ 0 - 23
web/apps/photos/src/components/WatchFolder/styledComponents.tsx

@@ -1,23 +0,0 @@
-import { VerticallyCentered } from "@ente/shared/components/Container";
-import { Box } from "@mui/material";
-import { styled } from "@mui/material/styles";
-
-export const MappingsContainer = styled(Box)(() => ({
-    height: "278px",
-    overflow: "auto",
-    "&::-webkit-scrollbar": {
-        width: "4px",
-    },
-}));
-
-export const NoMappingsContainer = styled(VerticallyCentered)({
-    textAlign: "left",
-    alignItems: "flex-start",
-    marginBottom: "32px",
-});
-
-export const EntryContainer = styled(Box)({
-    marginLeft: "12px",
-    marginRight: "6px",
-    marginBottom: "12px",
-});

+ 6 - 6
web/apps/photos/src/services/download/index.ts

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

+ 8 - 8
web/apps/photos/src/services/export/index.ts

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

+ 1 - 1
web/apps/photos/src/services/upload/uploadManager.ts

@@ -14,7 +14,7 @@ import {
     getPublicCollectionUID,
 } from "services/publicCollectionService";
 import { getDisableCFUploadProxyFlag } from "services/userService";
-import watchFolderService from "services/watchFolder/watchFolderService";
+import watchFolderService from "services/watch";
 import { Collection } from "types/collection";
 import { EncryptedEnteFile, EnteFile } from "types/file";
 import { SetFiles } from "types/gallery";

+ 113 - 17
web/apps/photos/src/services/watchFolder/watchFolderService.ts → web/apps/photos/src/services/watch.ts

@@ -1,3 +1,8 @@
+/**
+ * @file Interface with the Node.js layer of our desktop app to provide the
+ * watch folders functionality.
+ */
+
 import { ensureElectron } from "@/next/electron";
 import log from "@/next/log";
 import { UPLOAD_RESULT, UPLOAD_STRATEGY } from "constants/upload";
@@ -12,17 +17,11 @@ import {
     WatchMappingSyncedFile,
 } from "types/watchFolder";
 import { groupFilesBasedOnCollectionID } from "utils/file";
-import { getValidFilesToUpload } from "utils/watch";
-import { removeFromCollection } from "../collectionService";
-import { getLocalFiles } from "../fileService";
-import { getParentFolderName } from "./utils";
-import {
-    diskFileAddedCallback,
-    diskFileRemovedCallback,
-    diskFolderRemovedCallback,
-} from "./watchFolderEventHandlers";
+import { isSystemFile } from "utils/upload";
+import { removeFromCollection } from "./collectionService";
+import { getLocalFiles } from "./fileService";
 
-class watchFolderService {
+class WatchFolderService {
     private eventQueue: EventQueueItem[] = [];
     private currentEvent: EventQueueItem;
     private currentlySyncedMapping: WatchMapping;
@@ -196,12 +195,9 @@ class watchFolderService {
         }
     }
 
-    async removeWatchMapping(folderPath: string) {
-        try {
-            await ensureElectron().removeWatchMapping(folderPath);
-        } catch (e) {
-            log.error("error while removing watch mapping", e);
-        }
+    async mappingsAfterRemovingFolder(folderPath: string) {
+        await ensureElectron().removeWatchMapping(folderPath);
+        return await this.getWatchMappings();
     }
 
     async getWatchMappings(): Promise<WatchMapping[]> {
@@ -641,4 +637,104 @@ class watchFolderService {
     }
 }
 
-export default new watchFolderService();
+const watchFolderService = new WatchFolderService();
+
+export default watchFolderService;
+
+const getParentFolderName = (filePath: string) => {
+    const folderPath = filePath.substring(0, filePath.lastIndexOf("/"));
+    const folderName = folderPath.substring(folderPath.lastIndexOf("/") + 1);
+    return folderName;
+};
+
+async function diskFileAddedCallback(file: ElectronFile) {
+    try {
+        const collectionNameAndFolderPath =
+            await watchFolderService.getCollectionNameAndFolderPath(file.path);
+
+        if (!collectionNameAndFolderPath) {
+            return;
+        }
+
+        const { collectionName, folderPath } = collectionNameAndFolderPath;
+
+        const event: EventQueueItem = {
+            type: "upload",
+            collectionName,
+            folderPath,
+            files: [file],
+        };
+        watchFolderService.pushEvent(event);
+        log.info(
+            `added (upload) to event queue, collectionName:${event.collectionName} folderPath:${event.folderPath}, filesCount: ${event.files.length}`,
+        );
+    } catch (e) {
+        log.error("error while calling diskFileAddedCallback", e);
+    }
+}
+
+async function diskFileRemovedCallback(filePath: string) {
+    try {
+        const collectionNameAndFolderPath =
+            await watchFolderService.getCollectionNameAndFolderPath(filePath);
+
+        if (!collectionNameAndFolderPath) {
+            return;
+        }
+
+        const { collectionName, folderPath } = collectionNameAndFolderPath;
+
+        const event: EventQueueItem = {
+            type: "trash",
+            collectionName,
+            folderPath,
+            paths: [filePath],
+        };
+        watchFolderService.pushEvent(event);
+        log.info(
+            `added (trash) to event queue collectionName:${event.collectionName} folderPath:${event.folderPath} , pathsCount: ${event.paths.length}`,
+        );
+    } catch (e) {
+        log.error("error while calling diskFileRemovedCallback", e);
+    }
+}
+
+async function diskFolderRemovedCallback(folderPath: string) {
+    try {
+        const mappings = await watchFolderService.getWatchMappings();
+        const mapping = mappings.find(
+            (mapping) => mapping.folderPath === folderPath,
+        );
+        if (!mapping) {
+            log.info(`folder not found in mappings, ${folderPath}`);
+            throw Error(`Watch mapping not found`);
+        }
+        watchFolderService.pushTrashedDir(folderPath);
+        log.info(`added trashedDir, ${folderPath}`);
+    } catch (e) {
+        log.error("error while calling diskFolderRemovedCallback", e);
+    }
+}
+
+export function getValidFilesToUpload(
+    files: ElectronFile[],
+    mapping: WatchMapping,
+) {
+    const uniqueFilePaths = new Set<string>();
+    return files.filter((file) => {
+        if (!isSystemFile(file) && !isSyncedOrIgnoredFile(file, mapping)) {
+            if (!uniqueFilePaths.has(file.path)) {
+                uniqueFilePaths.add(file.path);
+                return true;
+            }
+        }
+        return false;
+    });
+}
+
+function isSyncedOrIgnoredFile(file: ElectronFile, mapping: WatchMapping) {
+    return (
+        mapping.ignoredFiles.includes(file.path) ||
+        mapping.syncedFiles.find((f) => f.path === file.path)
+    );
+}

+ 0 - 5
web/apps/photos/src/services/watchFolder/utils.ts

@@ -1,5 +0,0 @@
-export const getParentFolderName = (filePath: string) => {
-    const folderPath = filePath.substring(0, filePath.lastIndexOf("/"));
-    const folderName = folderPath.substring(folderPath.lastIndexOf("/") + 1);
-    return folderName;
-};

+ 0 - 73
web/apps/photos/src/services/watchFolder/watchFolderEventHandlers.ts

@@ -1,73 +0,0 @@
-import log from "@/next/log";
-import { ElectronFile } from "types/upload";
-import { EventQueueItem } from "types/watchFolder";
-import watchFolderService from "./watchFolderService";
-
-export async function diskFileAddedCallback(file: ElectronFile) {
-    try {
-        const collectionNameAndFolderPath =
-            await watchFolderService.getCollectionNameAndFolderPath(file.path);
-
-        if (!collectionNameAndFolderPath) {
-            return;
-        }
-
-        const { collectionName, folderPath } = collectionNameAndFolderPath;
-
-        const event: EventQueueItem = {
-            type: "upload",
-            collectionName,
-            folderPath,
-            files: [file],
-        };
-        watchFolderService.pushEvent(event);
-        log.info(
-            `added (upload) to event queue, collectionName:${event.collectionName} folderPath:${event.folderPath}, filesCount: ${event.files.length}`,
-        );
-    } catch (e) {
-        log.error("error while calling diskFileAddedCallback", e);
-    }
-}
-
-export async function diskFileRemovedCallback(filePath: string) {
-    try {
-        const collectionNameAndFolderPath =
-            await watchFolderService.getCollectionNameAndFolderPath(filePath);
-
-        if (!collectionNameAndFolderPath) {
-            return;
-        }
-
-        const { collectionName, folderPath } = collectionNameAndFolderPath;
-
-        const event: EventQueueItem = {
-            type: "trash",
-            collectionName,
-            folderPath,
-            paths: [filePath],
-        };
-        watchFolderService.pushEvent(event);
-        log.info(
-            `added (trash) to event queue collectionName:${event.collectionName} folderPath:${event.folderPath} , pathsCount: ${event.paths.length}`,
-        );
-    } catch (e) {
-        log.error("error while calling diskFileRemovedCallback", e);
-    }
-}
-
-export async function diskFolderRemovedCallback(folderPath: string) {
-    try {
-        const mappings = await watchFolderService.getWatchMappings();
-        const mapping = mappings.find(
-            (mapping) => mapping.folderPath === folderPath,
-        );
-        if (!mapping) {
-            log.info(`folder not found in mappings, ${folderPath}`);
-            throw Error(`Watch mapping not found`);
-        }
-        watchFolderService.pushTrashedDir(folderPath);
-        log.info(`added trashedDir, ${folderPath}`);
-    } catch (e) {
-        log.error("error while calling diskFolderRemovedCallback", e);
-    }
-}

+ 17 - 24
web/apps/photos/src/utils/file/index.ts

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

+ 58 - 0
web/apps/photos/src/utils/native-stream.ts

@@ -0,0 +1,58 @@
+/**
+ * @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) => {
+    // TODO(MR): This doesn't currently work.
+    //
+    // Not sure what I'm doing wrong here; I've opened an issue upstream
+    // https://github.com/electron/electron/issues/41872
+    //
+    // A gist with a minimal reproduction
+    // https://gist.github.com/mnvr/e08d9f4876fb8400b7615347b4d268eb
+    //
+    // Meanwhile, write the complete body in one go (this'll eventually run into
+    // memory failures with large files - just a temporary stopgap to get the
+    // code to work).
+
+    /*
+    // 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
+    const req = new Request(`stream://write${path}`, {
+        // GET can't have a body
+        method: "POST",
+        body: stream,
+        // @ts-expect-error TypeScript's libdom.d.ts does not include the
+        // "duplex" parameter, e.g. see
+        // https://github.com/node-fetch/node-fetch/issues/1769.
+        duplex: "half",
+    });
+    */
+
+    const req = new Request(`stream://write${path}`, {
+        method: "POST",
+        body: await new Response(stream).blob(),
+    });
+
+    const res = await fetch(req);
+    if (!res.ok)
+        throw new Error(
+            `Failed to write stream to ${path}: HTTP ${res.status}`,
+        );
+};

+ 0 - 26
web/apps/photos/src/utils/watch/index.ts

@@ -1,26 +0,0 @@
-import { ElectronFile } from "types/upload";
-import { WatchMapping } from "types/watchFolder";
-import { isSystemFile } from "utils/upload";
-
-function isSyncedOrIgnoredFile(file: ElectronFile, mapping: WatchMapping) {
-    return (
-        mapping.ignoredFiles.includes(file.path) ||
-        mapping.syncedFiles.find((f) => f.path === file.path)
-    );
-}
-
-export function getValidFilesToUpload(
-    files: ElectronFile[],
-    mapping: WatchMapping,
-) {
-    const uniqueFilePaths = new Set<string>();
-    return files.filter((file) => {
-        if (!isSystemFile(file) && !isSyncedOrIgnoredFile(file, mapping)) {
-            if (!uniqueFilePaths.has(file.path)) {
-                uniqueFilePaths.add(file.path);
-                return true;
-            }
-        }
-        return false;
-    });
-}

+ 97 - 97
web/packages/next/locales/de-DE/translation.json

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

+ 2 - 2
web/packages/next/locales/sv-SE/translation.json

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

+ 11 - 1
web/packages/next/next.config.base.js

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

+ 0 - 14
web/packages/next/types/file.ts

@@ -26,20 +26,6 @@ export interface DataStream {
     chunkCount: number;
 }
 
-export interface WatchMappingSyncedFile {
-    path: string;
-    uploadedFileID: number;
-    collectionID: number;
-}
-
-export interface WatchMapping {
-    rootFolderName: string;
-    folderPath: string;
-    uploadStrategy: UPLOAD_STRATEGY;
-    syncedFiles: WatchMappingSyncedFile[];
-    ignoredFiles: string[];
-}
-
 export interface EventQueueItem {
     type: "upload" | "trash";
     folderPath: string;

+ 42 - 10
web/packages/next/types/ipc.ts

@@ -3,7 +3,7 @@
 //
 // See [Note: types.ts <-> preload.ts <-> ipc.ts]
 
-import type { ElectronFile, WatchMapping } from "./file";
+import type { ElectronFile } from "./file";
 
 export interface AppUpdateInfo {
     autoUpdatable: boolean;
@@ -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>;
     };
 
     /*
@@ -287,25 +298,19 @@ export interface Electron {
 
     removeWatchMapping: (folderPath: string) => Promise<void>;
 
-    getWatchMappings: () => Promise<WatchMapping[]>;
+    getWatchMappings: () => Promise<FolderWatch[]>;
 
     updateWatchMappingSyncedFiles: (
         folderPath: string,
-        files: WatchMapping["syncedFiles"],
+        files: FolderWatch["syncedFiles"],
     ) => Promise<void>;
 
     updateWatchMappingIgnoredFiles: (
         folderPath: string,
-        files: WatchMapping["ignoredFiles"],
+        files: FolderWatch["ignoredFiles"],
     ) => 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
@@ -327,3 +332,30 @@ export interface Electron {
     setToUploadCollection: (collectionName: string) => Promise<void>;
     getDirFiles: (dirPath: string) => Promise<ElectronFile[]>;
 }
+
+/**
+ * A top level folder that was selected by the user for watching.
+ *
+ * The user can set up multiple such watches. Each of these can in turn be
+ * syncing multiple on disk folders to one or more (dependening on the
+ * {@link uploadStrategy}) Ente albums.
+ *
+ * This type is passed across the IPC boundary. It is persisted on the Node.js
+ * side.
+ */
+export interface FolderWatch {
+    rootFolderName: string;
+    uploadStrategy: number;
+    folderPath: string;
+    syncedFiles: FolderWatchSyncedFile[];
+    ignoredFiles: string[];
+}
+
+/**
+ * An on-disk file that was synced as part of a folder watch.
+ */
+export interface FolderWatchSyncedFile {
+    path: string;
+    uploadedFileID: number;
+    collectionID: number;
+}

+ 8 - 4
web/packages/shared/utils/index.ts

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