Browse Source

fix: logics and ui (button, error code)

Prateek Sunal 1 năm trước cách đây
mục cha
commit
d1a15b129a
100 tập tin đã thay đổi với 3174 bổ sung2955 xóa
  1. 3 3
      .github/workflows/auth-release.yml
  2. 30 0
      .github/workflows/desktop-lint.yml
  3. 1 0
      .github/workflows/mobile-internal-release.yml
  4. 22 10
      auth/lib/models/code.dart
  5. 11 0
      auth/lib/onboarding/view/setup_enter_secret_key_page.dart
  6. 4 5
      auth/lib/ui/code_widget.dart
  7. 1 0
      auth/lib/ui/settings/data/import/bitwarden_import.dart
  8. 2 2
      auth/lib/utils/totp_util.dart
  9. 1 1
      auth/linux/packaging/rpm/make_config.yaml
  10. 2 1
      auth/pubspec.yaml
  11. 8 1
      cli/README.md
  12. 19 9
      desktop/.eslintrc.js
  13. 0 55
      desktop/.github/workflows/build.yml
  14. 83 0
      desktop/.github/workflows/desktop-release.yml
  15. 8 0
      desktop/CHANGELOG.md
  16. 0 6
      desktop/README.md
  17. 4 1
      desktop/docs/dependencies.md
  18. 36 52
      desktop/docs/release.md
  19. 1 0
      desktop/electron-builder.yml
  20. 10 5
      desktop/package.json
  21. 101 82
      desktop/src/main.ts
  22. 0 54
      desktop/src/main/dialogs.ts
  23. 0 31
      desktop/src/main/fs.ts
  24. 0 63
      desktop/src/main/init.ts
  25. 60 51
      desktop/src/main/ipc.ts
  26. 6 6
      desktop/src/main/log.ts
  27. 11 8
      desktop/src/main/menu.ts
  28. 9 6
      desktop/src/main/services/app-update.ts
  29. 1 1
      desktop/src/main/services/auto-launcher.ts
  30. 51 0
      desktop/src/main/services/dir.ts
  31. 20 19
      desktop/src/main/services/ffmpeg.ts
  32. 19 166
      desktop/src/main/services/fs.ts
  33. 15 16
      desktop/src/main/services/image.ts
  34. 46 48
      desktop/src/main/services/ml-clip.ts
  35. 4 2
      desktop/src/main/services/ml-face.ts
  36. 8 5
      desktop/src/main/services/ml.ts
  37. 5 5
      desktop/src/main/services/store.ts
  38. 130 97
      desktop/src/main/services/upload.ts
  39. 19 22
      desktop/src/main/services/watch.ts
  40. 1 1
      desktop/src/main/stores/safe-storage.ts
  41. 30 6
      desktop/src/main/stores/upload-status.ts
  42. 1 1
      desktop/src/main/stores/user-preferences.ts
  43. 9 5
      desktop/src/main/stores/watch.ts
  44. 83 57
      desktop/src/main/stream.ts
  45. 0 63
      desktop/src/main/utils-temp.ts
  46. 12 3
      desktop/src/main/utils/common.ts
  47. 24 41
      desktop/src/main/utils/electron.ts
  48. 125 0
      desktop/src/main/utils/temp.ts
  49. 85 99
      desktop/src/preload.ts
  50. 5 0
      desktop/src/thirdparty/clip-bpe-ts/mod.ts
  51. 5 25
      desktop/src/types/ipc.ts
  52. 19 56
      desktop/tsconfig.json
  53. 390 210
      desktop/yarn.lock
  54. 1 1
      docs/docs/auth/migration-guides/authy/index.md
  55. 5 2
      docs/docs/self-hosting/guides/custom-server/index.md
  56. 0 6
      mobile/ios/Podfile.lock
  57. 0 2
      mobile/ios/Runner.xcodeproj/project.pbxproj
  58. 9 0
      mobile/ios/Runner/Info.plist
  59. 131 43
      mobile/lib/db/embeddings_db.dart
  60. 1 0
      mobile/lib/db/files_db.dart
  61. 12 4
      mobile/lib/gateways/cast_gw.dart
  62. 26 0
      mobile/lib/generated/intl/messages_en.dart
  63. 140 0
      mobile/lib/generated/l10n.dart
  64. 15 1
      mobile/lib/l10n/intl_en.arb
  65. 22 8
      mobile/lib/l10n/intl_pt.arb
  66. 15 1
      mobile/lib/l10n/intl_zh.arb
  67. 6 0
      mobile/lib/main.dart
  68. 0 10
      mobile/lib/models/embedding.dart
  69. 0 1059
      mobile/lib/models/embedding.g.dart
  70. 1 1
      mobile/lib/models/file/file.dart
  71. 8 0
      mobile/lib/service_locator.dart
  72. 1 1
      mobile/lib/services/machine_learning/semantic_search/embedding_store.dart
  73. 1 1
      mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart
  74. 1 1
      mobile/lib/services/update_service.dart
  75. 134 0
      mobile/lib/ui/cast/auto.dart
  76. 76 0
      mobile/lib/ui/cast/choose.dart
  77. 38 0
      mobile/lib/ui/common/popup_item.dart
  78. 4 3
      mobile/lib/ui/components/bottom_action_bar/selection_action_button_widget.dart
  79. 28 29
      mobile/lib/ui/notification/update/change_log_page.dart
  80. 67 31
      mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart
  81. 3 92
      mobile/lib/ui/viewer/file/file_app_bar.dart
  82. 208 255
      mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart
  83. 100 0
      mobile/lib/utils/file_download_util.dart
  84. 16 4
      mobile/lib/utils/file_uploader.dart
  85. 10 0
      mobile/plugins/ente_cast/.metadata
  86. 1 0
      mobile/plugins/ente_cast/analysis_options.yaml
  87. 2 0
      mobile/plugins/ente_cast/lib/ente_cast.dart
  88. 5 0
      mobile/plugins/ente_cast/lib/src/model.dart
  89. 18 0
      mobile/plugins/ente_cast/lib/src/service.dart
  90. 19 0
      mobile/plugins/ente_cast/pubspec.yaml
  91. 10 0
      mobile/plugins/ente_cast_none/.metadata
  92. 1 0
      mobile/plugins/ente_cast_none/analysis_options.yaml
  93. 1 0
      mobile/plugins/ente_cast_none/lib/ente_cast_none.dart
  94. 35 0
      mobile/plugins/ente_cast_none/lib/src/service.dart
  95. 18 0
      mobile/plugins/ente_cast_none/pubspec.yaml
  96. 10 0
      mobile/plugins/ente_cast_normal/.metadata
  97. 1 0
      mobile/plugins/ente_cast_normal/analysis_options.yaml
  98. 1 0
      mobile/plugins/ente_cast_normal/lib/ente_cast_normal.dart
  99. 100 0
      mobile/plugins/ente_cast_normal/lib/src/service.dart
  100. 333 0
      mobile/plugins/ente_cast_normal/pubspec.lock

+ 3 - 3
.github/workflows/auth-release.yml

@@ -17,8 +17,8 @@ name: "Release (auth)"
 # We use a suffix like `-test` to indicate that these are test tags, and that
 # We use a suffix like `-test` to indicate that these are test tags, and that
 # they belong to a pre-release.
 # they belong to a pre-release.
 #
 #
-# If you need to do multiple tests, add a +x at the end of the tag. e.g.
-# `auth-v1.2.3-test+1`.
+# If you need to do multiple tests, add a .x at the end of the tag. e.g.
+# `auth-v1.2.3-test.1`.
 #
 #
 # Once the testing is done, also delete the tag(s) please.
 # Once the testing is done, also delete the tag(s) please.
 
 
@@ -85,7 +85,7 @@ jobs:
             - name: Install dependencies for desktop build
             - name: Install dependencies for desktop build
               run: |
               run: |
                   sudo apt-get update -y
                   sudo apt-get update -y
-                  sudo apt-get install -y libsecret-1-dev libsodium-dev libwebkit2gtk-4.0-dev libfuse2 ninja-build libgtk-3-dev dpkg-dev pkg-config rpm libsqlite3-dev locate appindicator3-0.1 libappindicator3-dev libffi-dev libtiff5
+                  sudo apt-get install -y libsecret-1-dev libsodium-dev libwebkit2gtk-4.0-dev libfuse2 ninja-build libgtk-3-dev dpkg-dev pkg-config rpm patchelf libsqlite3-dev locate appindicator3-0.1 libappindicator3-dev libffi-dev libtiff5
                   sudo updatedb --localpaths='/usr/lib/x86_64-linux-gnu'
                   sudo updatedb --localpaths='/usr/lib/x86_64-linux-gnu'
 
 
             - name: Install appimagetool
             - name: Install appimagetool

+ 30 - 0
.github/workflows/desktop-lint.yml

@@ -0,0 +1,30 @@
+name: "Lint (desktop)"
+
+on:
+    # Run on every push to a branch other than main that changes desktop/
+    push:
+        branches-ignore: [main, "deploy/**"]
+        paths:
+            - "desktop/**"
+            - ".github/workflows/desktop-lint.yml"
+
+jobs:
+    lint:
+        runs-on: ubuntu-latest
+        defaults:
+            run:
+                working-directory: desktop
+        steps:
+            - name: Checkout code
+              uses: actions/checkout@v4
+
+            - name: Setup node and enable yarn caching
+              uses: actions/setup-node@v4
+              with:
+                  node-version: 20
+                  cache: "yarn"
+                  cache-dependency-path: "desktop/yarn.lock"
+
+            - run: yarn install
+
+            - run: yarn lint

+ 1 - 0
.github/workflows/mobile-internal-release.yml

@@ -54,3 +54,4 @@ jobs:
                   packageName: io.ente.photos
                   packageName: io.ente.photos
                   releaseFiles: mobile/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab
                   releaseFiles: mobile/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab
                   track: internal
                   track: internal
+                  changesNotSentForReview: true

+ 22 - 10
auth/lib/models/code.dart

@@ -1,11 +1,9 @@
-import 'dart:convert';
-
-import 'package:ente_auth/models/code_display.dart';
 import 'package:ente_auth/utils/totp_util.dart';
 import 'package:ente_auth/utils/totp_util.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/foundation.dart';
 
 
 class Code {
 class Code {
   static const defaultDigits = 6;
   static const defaultDigits = 6;
+  static const steamDigits = 5;
   static const defaultPeriod = 30;
   static const defaultPeriod = 30;
 
 
   int? generatedID;
   int? generatedID;
@@ -70,39 +68,45 @@ class Code {
       updatedAlgo,
       updatedAlgo,
       updatedType,
       updatedType,
       updatedCounter,
       updatedCounter,
-      "otpauth://${updatedType.name}/$updateIssuer:$updateAccount?algorithm=${updatedAlgo.name}&digits=$updatedDigits&issuer=$updateIssuer&period=$updatePeriod&secret=$updatedSecret${updatedType == Type.hotp ? "&counter=$updatedCounter" : ""}",
+      "otpauth://${updatedType.name}/$updateIssuer:$updateAccount?algorithm=${updatedAlgo.name}"
+      "&digits=$updatedDigits&issuer=$updateIssuer"
+      "&period=$updatePeriod&secret=$updatedSecret${updatedType == Type.hotp ? "&counter=$updatedCounter" : ""}",
       generatedID: generatedID,
       generatedID: generatedID,
       display: updatedDisplay,
       display: updatedDisplay,
     );
     );
   }
   }
 
 
   static Code fromAccountAndSecret(
   static Code fromAccountAndSecret(
+    Type type,
     String account,
     String account,
     String issuer,
     String issuer,
     String secret,
     String secret,
     CodeDisplay? display,
     CodeDisplay? display,
+    int digits,
   ) {
   ) {
     return Code(
     return Code(
       account,
       account,
       issuer,
       issuer,
-      defaultDigits,
+      digits,
       defaultPeriod,
       defaultPeriod,
       secret,
       secret,
       Algorithm.sha1,
       Algorithm.sha1,
-      Type.totp,
+      type,
       0,
       0,
-      "otpauth://totp/$issuer:$account?algorithm=SHA1&digits=6&issuer=$issuer&period=30&secret=$secret",
+      "otpauth://${type.name}/$issuer:$account?algorithm=SHA1&digits=$digits&issuer=$issuer&period=30&secret=$secret",
       display: display ?? CodeDisplay(),
       display: display ?? CodeDisplay(),
     );
     );
   }
   }
 
 
   static Code fromOTPAuthUrl(String rawData, {CodeDisplay? display}) {
   static Code fromOTPAuthUrl(String rawData, {CodeDisplay? display}) {
     Uri uri = Uri.parse(rawData);
     Uri uri = Uri.parse(rawData);
+    final issuer = _getIssuer(uri);
+
     try {
     try {
       return Code(
       return Code(
         _getAccount(uri),
         _getAccount(uri),
-        _getIssuer(uri),
-        _getDigits(uri),
+        issuer,
+        _getDigits(uri, issuer),
         _getPeriod(uri),
         _getPeriod(uri),
         getSanitizedSecret(uri.queryParameters['secret']!),
         getSanitizedSecret(uri.queryParameters['secret']!),
         _getAlgorithm(uri),
         _getAlgorithm(uri),
@@ -179,10 +183,13 @@ class Code {
     }
     }
   }
   }
 
 
-  static int _getDigits(Uri uri) {
+  static int _getDigits(Uri uri, String issuer) {
     try {
     try {
       return int.parse(uri.queryParameters['digits']!);
       return int.parse(uri.queryParameters['digits']!);
     } catch (e) {
     } catch (e) {
+      if (issuer.toLowerCase() == "steam") {
+        return steamDigits;
+      }
       return defaultDigits;
       return defaultDigits;
     }
     }
   }
   }
@@ -225,6 +232,8 @@ class Code {
   static Type _getType(Uri uri) {
   static Type _getType(Uri uri) {
     if (uri.host == "totp") {
     if (uri.host == "totp") {
       return Type.totp;
       return Type.totp;
+    } else if (uri.host == "steam") {
+      return Type.steam;
     } else if (uri.host == "hotp") {
     } else if (uri.host == "hotp") {
       return Type.hotp;
       return Type.hotp;
     }
     }
@@ -262,6 +271,9 @@ class Code {
 enum Type {
 enum Type {
   totp,
   totp,
   hotp,
   hotp,
+  steam;
+
+  bool get isTOTPCompatible => this == totp || this == steam;
 }
 }
 
 
 enum Algorithm {
 enum Algorithm {

+ 11 - 0
auth/lib/onboarding/view/setup_enter_secret_key_page.dart

@@ -90,6 +90,8 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
                   },
                   },
                   decoration: InputDecoration(
                   decoration: InputDecoration(
                     hintText: l10n.codeIssuerHint,
                     hintText: l10n.codeIssuerHint,
+                    floatingLabelBehavior: FloatingLabelBehavior.auto,
+                    labelText: l10n.codeIssuerHint,
                   ),
                   ),
                   controller: _issuerController,
                   controller: _issuerController,
                   autofocus: true,
                   autofocus: true,
@@ -107,6 +109,8 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
                   },
                   },
                   decoration: InputDecoration(
                   decoration: InputDecoration(
                     hintText: l10n.codeSecretKeyHint,
                     hintText: l10n.codeSecretKeyHint,
+                    floatingLabelBehavior: FloatingLabelBehavior.auto,
+                    labelText: l10n.codeSecretKeyHint,
                     suffixIcon: IconButton(
                     suffixIcon: IconButton(
                       onPressed: () {
                       onPressed: () {
                         setState(() {
                         setState(() {
@@ -134,9 +138,12 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
                   },
                   },
                   decoration: InputDecoration(
                   decoration: InputDecoration(
                     hintText: l10n.codeAccountHint,
                     hintText: l10n.codeAccountHint,
+                    floatingLabelBehavior: FloatingLabelBehavior.auto,
+                    labelText: l10n.codeAccountHint,
                   ),
                   ),
                   controller: _accountController,
                   controller: _accountController,
                 ),
                 ),
+                const SizedBox(height: 40),
                 const SizedBox(
                 const SizedBox(
                   height: 20,
                   height: 20,
                 ),
                 ),
@@ -218,6 +225,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
                     child: Padding(
                     child: Padding(
                       padding: const EdgeInsets.symmetric(
                       padding: const EdgeInsets.symmetric(
                         horizontal: 16.0,
                         horizontal: 16.0,
+                        vertical: 4,
                       ),
                       ),
                       child: Text(l10n.saveAction),
                       child: Text(l10n.saveAction),
                     ),
                     ),
@@ -236,6 +244,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
       final account = _accountController.text.trim();
       final account = _accountController.text.trim();
       final issuer = _issuerController.text.trim();
       final issuer = _issuerController.text.trim();
       final secret = _secretController.text.trim().replaceAll(' ', '');
       final secret = _secretController.text.trim().replaceAll(' ', '');
+      final isStreamCode = issuer.toLowerCase() == "steam";
       if (widget.code != null && widget.code!.secret != secret) {
       if (widget.code != null && widget.code!.secret != secret) {
         ButtonResult? result = await showChoiceActionSheet(
         ButtonResult? result = await showChoiceActionSheet(
           context,
           context,
@@ -253,9 +262,11 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
       final CodeDisplay display = widget.code!.display.copyWith(tags: tags);
       final CodeDisplay display = widget.code!.display.copyWith(tags: tags);
       final Code newCode = widget.code == null
       final Code newCode = widget.code == null
           ? Code.fromAccountAndSecret(
           ? Code.fromAccountAndSecret(
+              isStreamCode ? Type.steam : Type.totp,
               account,
               account,
               issuer,
               issuer,
               secret,
               secret,
+              isStreamCode ? Code.steamDigits : Code.defaultDigits,
               display,
               display,
             )
             )
           : widget.code!.copyWith(
           : widget.code!.copyWith(

+ 4 - 5
auth/lib/ui/code_widget.dart

@@ -1,6 +1,5 @@
 import 'dart:async';
 import 'dart:async';
 import 'dart:io';
 import 'dart:io';
-import 'dart:ui' as ui;
 
 
 import 'package:clipboard/clipboard.dart';
 import 'package:clipboard/clipboard.dart';
 import 'package:ente_auth/core/configuration.dart';
 import 'package:ente_auth/core/configuration.dart';
@@ -61,7 +60,7 @@ class _CodeWidgetState extends State<CodeWidget> {
       String newCode = _getCurrentOTP();
       String newCode = _getCurrentOTP();
       if (newCode != _currentCode.value) {
       if (newCode != _currentCode.value) {
         _currentCode.value = newCode;
         _currentCode.value = newCode;
-        if (widget.code.type == Type.totp) {
+        if (widget.code.type.isTOTPCompatible) {
           _nextCode.value = _getNextTotp();
           _nextCode.value = _getNextTotp();
         }
         }
       }
       }
@@ -86,7 +85,7 @@ class _CodeWidgetState extends State<CodeWidget> {
     _shouldShowLargeIcon = PreferenceService.instance.shouldShowLargeIcons();
     _shouldShowLargeIcon = PreferenceService.instance.shouldShowLargeIcons();
     if (!_isInitialized) {
     if (!_isInitialized) {
       _currentCode.value = _getCurrentOTP();
       _currentCode.value = _getCurrentOTP();
-      if (widget.code.type == Type.totp) {
+      if (widget.code.type.isTOTPCompatible) {
         _nextCode.value = _getNextTotp();
         _nextCode.value = _getNextTotp();
       }
       }
       _isInitialized = true;
       _isInitialized = true;
@@ -373,7 +372,7 @@ class _CodeWidgetState extends State<CodeWidget> {
               },
               },
             ),
             ),
           ),
           ),
-          widget.code.type == Type.totp
+          widget.code.type.isTOTPCompatible
               ? GestureDetector(
               ? GestureDetector(
                   onTap: () {
                   onTap: () {
                     _copyNextToClipboard();
                     _copyNextToClipboard();
@@ -610,7 +609,7 @@ class _CodeWidgetState extends State<CodeWidget> {
 
 
   String _getNextTotp() {
   String _getNextTotp() {
     try {
     try {
-      assert(widget.code.type == Type.totp);
+      assert(widget.code.type.isTOTPCompatible);
       return getNextTotp(widget.code);
       return getNextTotp(widget.code);
     } catch (e) {
     } catch (e) {
       return context.l10n.error;
       return context.l10n.error;

+ 1 - 0
auth/lib/ui/settings/data/import/bitwarden_import.dart

@@ -92,6 +92,7 @@ Future<int?> _processBitwardenExportFile(
         var account = item['login']['username'];
         var account = item['login']['username'];
 
 
         code = Code.fromAccountAndSecret(
         code = Code.fromAccountAndSecret(
+          Type.totp,
           account,
           account,
           issuer,
           issuer,
           totp,
           totp,

+ 2 - 2
auth/lib/utils/totp_util.dart

@@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart';
 import 'package:otp/otp.dart' as otp;
 import 'package:otp/otp.dart' as otp;
 
 
 String getOTP(Code code) {
 String getOTP(Code code) {
-  if(code.type == Type.hotp) {
+  if (code.type == Type.hotp) {
     return _getHOTPCode(code);
     return _getHOTPCode(code);
   }
   }
   return otp.OTP.generateTOTPCodeString(
   return otp.OTP.generateTOTPCodeString(
@@ -60,4 +60,4 @@ String safeDecode(String value) {
     debugPrint("Failed to decode $e");
     debugPrint("Failed to decode $e");
     return value;
     return value;
   }
   }
-}
+}

+ 1 - 1
auth/linux/packaging/rpm/make_config.yaml

@@ -11,7 +11,7 @@ display_name: Auth
 
 
 requires:
 requires:
   - libsqlite3x
   - libsqlite3x
-  - webkit2gtk-4.0
+  - webkit2gtk4.0
   - libsodium
   - libsodium
   - libsecret
   - libsecret
   - libappindicator
   - libappindicator

+ 2 - 1
auth/pubspec.yaml

@@ -20,7 +20,8 @@ dependencies:
   convert: ^3.1.1
   convert: ^3.1.1
   desktop_webview_window:
   desktop_webview_window:
     git:
     git:
-      url: https://github.com/MixinNetwork/flutter-plugins
+      url: https://github.com/ente-io/flutter-desktopwebview-fork
+      ref: fix-webkit-version
       path: packages/desktop_webview_window
       path: packages/desktop_webview_window
   device_info_plus: ^9.1.1
   device_info_plus: ^9.1.1
   dio: ^5.4.0
   dio: ^5.4.0

+ 8 - 1
cli/README.md

@@ -36,7 +36,8 @@ ente --help
 
 
 ### Accounts
 ### Accounts
 
 
-If you wish, you can add multiple accounts (your own and that of your family members) and export all data using this tool.
+If you wish, you can add multiple accounts (your own and that of your family
+members) and export all data using this tool.
 
 
 #### Add an account
 #### Add an account
 
 
@@ -44,6 +45,12 @@ If you wish, you can add multiple accounts (your own and that of your family mem
 ente account add
 ente account add
 ```
 ```
 
 
+> [!NOTE]
+>
+> `ente account add` does not create new accounts, it just adds pre-existing
+> accounts to the list of accounts that the CLI knows about so that you can use
+> them for other actions.
+
 #### List accounts
 #### List accounts
 
 
 ```shell
 ```shell

+ 19 - 9
desktop/.eslintrc.js

@@ -1,26 +1,36 @@
 /* eslint-env node */
 /* eslint-env node */
 module.exports = {
 module.exports = {
+    root: true,
     extends: [
     extends: [
         "eslint:recommended",
         "eslint:recommended",
         "plugin:@typescript-eslint/eslint-recommended",
         "plugin:@typescript-eslint/eslint-recommended",
-        /* What we really want eventually */
-        // "plugin:@typescript-eslint/strict-type-checked",
-        // "plugin:@typescript-eslint/stylistic-type-checked",
+        "plugin:@typescript-eslint/strict-type-checked",
+        "plugin:@typescript-eslint/stylistic-type-checked",
     ],
     ],
-    /* Temporarily add a global
-       Enhancement: Remove me */
-    globals: {
-        NodeJS: "readonly",
-    },
     plugins: ["@typescript-eslint"],
     plugins: ["@typescript-eslint"],
     parser: "@typescript-eslint/parser",
     parser: "@typescript-eslint/parser",
     parserOptions: {
     parserOptions: {
         project: true,
         project: true,
     },
     },
-    root: true,
     ignorePatterns: [".eslintrc.js", "app", "out", "dist"],
     ignorePatterns: [".eslintrc.js", "app", "out", "dist"],
     env: {
     env: {
         es2022: true,
         es2022: true,
         node: true,
         node: true,
     },
     },
+    rules: {
+        /* Allow numbers to be used in template literals */
+        "@typescript-eslint/restrict-template-expressions": [
+            "error",
+            {
+                allowNumber: true,
+            },
+        ],
+        /* Allow void expressions as the entire body of an arrow function */
+        "@typescript-eslint/no-confusing-void-expression": [
+            "error",
+            {
+                ignoreArrowShorthand: true,
+            },
+        ],
+    },
 };
 };

+ 0 - 55
desktop/.github/workflows/build.yml

@@ -1,55 +0,0 @@
-name: Build/release
-
-on:
-    push:
-        tags:
-            - v*
-
-jobs:
-    release:
-        runs-on: ${{ matrix.os }}
-
-        strategy:
-            matrix:
-                os: [macos-latest, ubuntu-latest, windows-latest]
-
-        steps:
-            - name: Check out Git repository
-              uses: actions/checkout@v3
-              with:
-                  submodules: recursive
-
-            - name: Install Node.js, NPM and Yarn
-              uses: actions/setup-node@v3
-              with:
-                  node-version: 20
-
-            - name: Prepare for app notarization
-              if: startsWith(matrix.os, 'macos')
-              # Import Apple API key for app notarization on macOS
-              run: |
-                  mkdir -p ~/private_keys/
-                  echo '${{ secrets.api_key }}' > ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
-
-            - name: Install libarchive-tools for pacman build # Related https://github.com/electron-userland/electron-builder/issues/4181
-              if: startsWith(matrix.os, 'ubuntu')
-              run: sudo apt-get install libarchive-tools
-
-            - name: Ente Electron Builder Action
-              uses: ente-io/action-electron-builder@v1.0.0
-              with:
-                  # GitHub token, automatically provided to the action
-                  # (No need to define this secret in the repo settings)
-                  github_token: ${{ secrets.github_token }}
-
-                  # If the commit is tagged with a version (e.g. "v1.0.0"),
-                  # release the app after building
-                  release: ${{ startsWith(github.ref, 'refs/tags/v') }}
-
-                  mac_certs: ${{ secrets.mac_certs }}
-                  mac_certs_password: ${{ secrets.mac_certs_password }}
-              env:
-                  # macOS notarization API key
-                  API_KEY_ID: ${{ secrets.api_key_id }}
-                  API_KEY_ISSUER_ID: ${{ secrets.api_key_issuer_id}}
-                  USE_HARD_LINKS: false

+ 83 - 0
desktop/.github/workflows/desktop-release.yml

@@ -0,0 +1,83 @@
+name: "Release"
+
+# This will create a new draft release with public artifacts.
+#
+# Note that a release will only get created if there is an associated tag
+# (GitHub releases need a corresponding tag).
+#
+# The canonical source for this action is in the repository where we keep the
+# source code for the Ente Photos desktop app: https://github.com/ente-io/ente
+#
+# However, it actually lives and runs in the repository that we use for making
+# releases: https://github.com/ente-io/photos-desktop
+#
+# We need two repositories because Electron updater currently doesn't work well
+# with monorepos. For more details, see `docs/release.md`.
+
+on:
+    push:
+        # Run when a tag matching the pattern "v*"" is pushed.
+        #
+        # See: [Note: Testing release workflows that are triggered by tags].
+        tags:
+            - "v*"
+
+jobs:
+    release:
+        runs-on: ${{ matrix.os }}
+
+        defaults:
+            run:
+                working-directory: desktop
+
+        strategy:
+            matrix:
+                os: [macos-latest, ubuntu-latest, windows-latest]
+
+        steps:
+            - name: Checkout code
+              uses: actions/checkout@v4
+              with:
+                  # Checkout the tag photosd-v1.x.x from the source code
+                  # repository when we're invoked for tag v1.x.x on the releases
+                  # repository.
+                  repository: ente-io/ente
+                  ref: photosd-${{ github.ref_name }}
+                  submodules: recursive
+
+            - name: Setup node
+              uses: actions/setup-node@v4
+              with:
+                  node-version: 20
+
+            - name: Install dependencies
+              run: yarn install
+
+            - name: Install libarchive-tools for pacman build
+              if: startsWith(matrix.os, 'ubuntu')
+              # See:
+              # https://github.com/electron-userland/electron-builder/issues/4181
+              run: sudo apt-get install libarchive-tools
+
+            - name: Build
+              uses: ente-io/action-electron-builder@v1.0.0
+              with:
+                  package_root: desktop
+
+                  # GitHub token, automatically provided to the action
+                  # (No need to define this secret in the repo settings)
+                  github_token: ${{ secrets.GITHUB_TOKEN }}
+
+                  # If the commit is tagged with a version (e.g. "v1.0.0"),
+                  # release the app after building.
+                  release: ${{ startsWith(github.ref, 'refs/tags/v') }}
+
+                  mac_certs: ${{ secrets.MAC_CERTS }}
+                  mac_certs_password: ${{ secrets.MAC_CERTS_PASSWORD }}
+              env:
+                  # macOS notarization credentials key details
+                  APPLE_ID: ${{ secrets.APPLE_ID }}
+                  APPLE_APP_SPECIFIC_PASSWORD:
+                      ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
+                  APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+                  USE_HARD_LINKS: false

+ 8 - 0
desktop/CHANGELOG.md

@@ -1,5 +1,13 @@
 # CHANGELOG
 # CHANGELOG
 
 
+## v1.7.0 (Unreleased)
+
+v1.7 is a major rewrite to improve the security of our app. We have enabled
+sandboxing and disabled node integration for the renderer process. All this
+required restructuring our IPC mechanisms, which resulted in a lot of under the
+hood changes. The outcome is a more secure app that also uses the latest and
+greatest Electron recommendations.
+
 ## v1.6.63
 ## v1.6.63
 
 
 ### New
 ### New

+ 0 - 6
desktop/README.md

@@ -10,12 +10,6 @@ To know more about Ente, see [our main README](../README.md) or visit
 
 
 ## Building from source
 ## Building from source
 
 
-> [!CAUTION]
->
-> We're improving the security of the desktop app further by migrating to
-> Electron's sandboxing and contextIsolation. These updates are still WIP and
-> meanwhile the instructions below might not fully work on the main branch.
-
 Fetch submodules
 Fetch submodules
 
 
 ```sh
 ```sh

+ 4 - 1
desktop/docs/dependencies.md

@@ -13,7 +13,7 @@ Electron embeds Chromium and Node.js in the generated app's binary. The
 generated app thus consists of two separate processes - the _main_ process, and
 generated app thus consists of two separate processes - the _main_ process, and
 a _renderer_ process.
 a _renderer_ process.
 
 
--   The _main_ process is runs the embedded node. This process can deal with the
+-   The _main_ process runs the embedded node. This process can deal with the
     host OS - it is conceptually like a `node` repl running on your machine. In
     host OS - it is conceptually like a `node` repl running on your machine. In
     our case, the TypeScript code (in the `src/` directory) gets transpiled by
     our case, the TypeScript code (in the `src/` directory) gets transpiled by
     `tsc` into JavaScript in the `build/app/` directory, which gets bundled in
     `tsc` into JavaScript in the `build/app/` directory, which gets bundled in
@@ -90,6 +90,9 @@ Some extra ones specific to the code here are:
     Unix commands in our `package.json` scripts. This allows us to use the same
     Unix commands in our `package.json` scripts. This allows us to use the same
     commands (like `ln`) across different platforms like Linux and Windows.
     commands (like `ln`) across different platforms like Linux and Windows.
 
 
+-   [@tsconfig/recommended](https://github.com/tsconfig/bases) gives us a base
+    tsconfig for the Node.js version that our current Electron version uses.
+
 ## Functionality
 ## Functionality
 
 
 ### Format conversion
 ### Format conversion

+ 36 - 52
desktop/docs/release.md

@@ -1,43 +1,47 @@
 ## Releases
 ## Releases
 
 
-> [!NOTE]
->
-> TODO(MR): This document needs to be audited and changed as we do the first
-> release from this new monorepo.
+Conceptually, the release is straightforward: We push a tag, a GitHub workflow
+gets triggered that creates a draft release with artifacts built from that tag.
+We then publish that release. The download links on our website, and existing
+apps already know how to check for the latest GitHub release and update
+accordingly.
 
 
-The Github Action that builds the desktop binaries is triggered by pushing a tag
-matching the pattern `photos-desktop-v1.2.3`. This value should match the
-version in `package.json`.
+The complication comes by the fact that Electron Updater (the mechanism that we
+use for auto updates) doesn't work well with monorepos. So we need to keep a
+separate (non-mono) repository just for doing releases.
 
 
-So the process for doing a release would be.
+-   Source code lives here, in [ente-io/ente](https://github.com/ente-io/ente).
 
 
-1. Create a new branch (can be named anything). On this branch, include your
-   changes.
+-   Releases are done from
+    [ente-io/photos-desktop](https://github.com/ente-io/photos-desktop).
 
 
-2. Mention the changes in `CHANGELOG.md`.
+## Workflow
 
 
-3. Changing the `version` in `package.json` to `1.x.x`.
+The workflow is:
 
 
-4. Commit and push to remote
+1.  Finalize the changes in the source repo.
+
+    -   Update the CHANGELOG.
+    -   Update the version in `package.json`
+    -   `git commit -m "[photosd] Release v1.2.3"`
+    -   Open PR, merge into main.
+
+2.  Tag the merge commit with a tag matching the pattern `photosd-v1.2.3`, where
+    `1.2.3` is the version in `package.json`
 
 
     ```sh
     ```sh
-    git add package.json && git commit -m 'Release v1.x.x'
-    git tag v1.x.x
-    git push && git push --tags
+    git tag photosd-v1.x.x
+    git push origin photosd-v1.x.x
     ```
     ```
 
 
-This by itself will already trigger a new release. The GitHub action will create
-a new draft release that can then be used as descibed below.
-
-To wrap up, we also need to merge back these changes into main. So for that,
+3.  Head over to the releases repository and run the trigger script, passing it
+    the tag _without_ the `photosd-` prefix.
 
 
-5. Open a PR for the branch that we're working on (where the above tag was
-   pushed from) to get it merged into main.
+    ```sh
+    ./.github/trigger-release.sh v1.x.x
+    ```
 
 
-6. In this PR, also increase the version number for the next release train. That
-   is, supposed we just released `v4.0.1`. Then we'll change the version number
-   in main to `v4.0.2-next.0`. Each pre-release will modify the `next.0` part.
-   Finally, at the time of the next release, this'll become `v4.0.2`.
+## Post build
 
 
 The GitHub Action runs on Windows, Linux and macOS. It produces the artifacts
 The GitHub Action runs on Windows, Linux and macOS. It produces the artifacts
 defined in the `build` value in `package.json`.
 defined in the `build` value in `package.json`.
@@ -46,29 +50,11 @@ defined in the `build` value in `package.json`.
 -   Linux - An AppImage, and 3 other packages (`.rpm`, `.deb`, `.pacman`)
 -   Linux - An AppImage, and 3 other packages (`.rpm`, `.deb`, `.pacman`)
 -   macOS - A universal DMG
 -   macOS - A universal DMG
 
 
-Additionally, the GitHub action notarizes the macOS DMG. For this it needs
-credentials provided via GitHub secrets.
-
-During the build the Sentry webpack plugin checks to see if SENTRY_AUTH_TOKEN is
-defined. If so, it uploads the sourcemaps for the renderer process to Sentry
-(For our GitHub action, the SENTRY_AUTH_TOKEN is defined as a GitHub secret).
-
-The sourcemaps for the main (node) process are currently not sent to Sentry
-(this works fine in practice since the node process files are not minified, we
-only run `tsc`).
-
-Once the build is done, a draft release with all these artifacts attached is
-created. The build is idempotent, so if something goes wrong and we need to
-re-run the GitHub action, just delete the draft release (if it got created) and
-start a new run by pushing a new tag (if some code changes are required).
-
-If no code changes are required, say the build failed for some transient network
-or sentry issue, we can even be re-run by the build by going to Github Action
-age and rerun from there. This will re-trigger for the same tag.
+Additionally, the GitHub action notarizes and signs the macOS DMG (For this it
+uses credentials provided via GitHub secrets).
 
 
-If everything goes well, we'll have a release on GitHub, and the corresponding
-source maps for the renderer process uploaded to Sentry. There isn't anything
-else to do:
+To rollout the build, we need to publish the draft release. Thereafter,
+everything is automated:
 
 
 -   The website automatically redirects to the latest release on GitHub when
 -   The website automatically redirects to the latest release on GitHub when
     people try to download.
     people try to download.
@@ -76,7 +62,7 @@ else to do:
 -   The file formats with support auto update (Windows `exe`, the Linux AppImage
 -   The file formats with support auto update (Windows `exe`, the Linux AppImage
     and the macOS DMG) also check the latest GitHub release automatically to
     and the macOS DMG) also check the latest GitHub release automatically to
     download and apply the update (the rest of the formats don't support auto
     download and apply the update (the rest of the formats don't support auto
-    updates).
+    updates yet).
 
 
 -   We're not putting the desktop app in other stores currently. It is available
 -   We're not putting the desktop app in other stores currently. It is available
     as a `brew cask`, but we only had to open a PR to add the initial formula,
     as a `brew cask`, but we only had to open a PR to add the initial formula,
@@ -87,6 +73,4 @@ else to do:
 We can also publish the draft releases by checking the "pre-release" option.
 We can also publish the draft releases by checking the "pre-release" option.
 Such releases don't cause any of the channels (our website, or the desktop app
 Such releases don't cause any of the channels (our website, or the desktop app
 auto updater, or brew) to be notified, instead these are useful for giving links
 auto updater, or brew) to be notified, instead these are useful for giving links
-to pre-release builds to customers. Generally, in the version number for these
-we'll add a label to the version, e.g. the "beta.x" in `1.x.x-beta.x`. This
-should be done both in `package.json`, and what we tag the commit with.
+to pre-release builds to customers.

+ 1 - 0
desktop/electron-builder.yml

@@ -29,4 +29,5 @@ mac:
         arch: [universal]
         arch: [universal]
     category: public.app-category.photography
     category: public.app-category.photography
     hardenedRuntime: true
     hardenedRuntime: true
+    notarize: true
 afterSign: electron-builder-notarize
 afterSign: electron-builder-notarize

+ 10 - 5
desktop/package.json

@@ -1,8 +1,9 @@
 {
 {
     "name": "ente",
     "name": "ente",
-    "version": "1.6.63",
+    "version": "1.7.0-beta.0",
     "private": true,
     "private": true,
     "description": "Desktop client for Ente Photos",
     "description": "Desktop client for Ente Photos",
+    "repository": "github:ente-io/photos-desktop",
     "author": "Ente <code@ente.io>",
     "author": "Ente <code@ente.io>",
     "main": "app/main.js",
     "main": "app/main.js",
     "scripts": {
     "scripts": {
@@ -15,8 +16,11 @@
         "dev-main": "tsc && electron app/main.js",
         "dev-main": "tsc && electron app/main.js",
         "dev-renderer": "cd ../web && yarn install && yarn dev:photos",
         "dev-renderer": "cd ../web && yarn install && yarn dev:photos",
         "postinstall": "electron-builder install-app-deps",
         "postinstall": "electron-builder install-app-deps",
-        "lint": "yarn prettier --check . && eslint --ext .ts src",
-        "lint-fix": "yarn prettier --write . && eslint --fix --ext .ts src"
+        "lint": "yarn prettier --check --log-level warn . && eslint --ext .ts src && yarn tsc",
+        "lint-fix": "yarn prettier --write --log-level warn . && eslint --fix --ext .ts src && yarn tsc"
+    },
+    "resolutions": {
+        "jackspeak": "2.1.1"
     },
     },
     "dependencies": {
     "dependencies": {
         "any-shell-escape": "^0.1",
         "any-shell-escape": "^0.1",
@@ -34,13 +38,14 @@
         "onnxruntime-node": "^1.17"
         "onnxruntime-node": "^1.17"
     },
     },
     "devDependencies": {
     "devDependencies": {
+        "@tsconfig/node20": "^20.1.4",
         "@types/auto-launch": "^5.0",
         "@types/auto-launch": "^5.0",
         "@types/ffmpeg-static": "^3.0",
         "@types/ffmpeg-static": "^3.0",
         "@typescript-eslint/eslint-plugin": "^7",
         "@typescript-eslint/eslint-plugin": "^7",
         "@typescript-eslint/parser": "^7",
         "@typescript-eslint/parser": "^7",
         "concurrently": "^8",
         "concurrently": "^8",
-        "electron": "^29",
-        "electron-builder": "^24",
+        "electron": "^30",
+        "electron-builder": "25.0.0-alpha.6",
         "electron-builder-notarize": "^1.5",
         "electron-builder-notarize": "^1.5",
         "eslint": "^8",
         "eslint": "^8",
         "prettier": "^3",
         "prettier": "^3",

+ 101 - 82
desktop/src/main.ts

@@ -8,18 +8,15 @@
  *
  *
  * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
  * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
  */
  */
-import { nativeImage } from "electron";
-import { app, BrowserWindow, Menu, protocol, Tray } from "electron/main";
+
+import { nativeImage, shell } from "electron/common";
+import type { WebContents } from "electron/main";
+import { BrowserWindow, Menu, Tray, app, protocol } from "electron/main";
 import serveNextAt from "next-electron-server";
 import serveNextAt from "next-electron-server";
 import { existsSync } from "node:fs";
 import { existsSync } from "node:fs";
 import fs from "node:fs/promises";
 import fs from "node:fs/promises";
 import os from "node:os";
 import os from "node:os";
 import path from "node:path";
 import path from "node:path";
-import {
-    addAllowOriginHeader,
-    handleDownloads,
-    handleExternalLinks,
-} from "./main/init";
 import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
 import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
 import log, { initLogging } from "./main/log";
 import log, { initLogging } from "./main/log";
 import { createApplicationMenu, createTrayContextMenu } from "./main/menu";
 import { createApplicationMenu, createTrayContextMenu } from "./main/menu";
@@ -29,12 +26,12 @@ import { createWatcher } from "./main/services/watch";
 import { userPreferences } from "./main/stores/user-preferences";
 import { userPreferences } from "./main/stores/user-preferences";
 import { migrateLegacyWatchStoreIfNeeded } from "./main/stores/watch";
 import { migrateLegacyWatchStoreIfNeeded } from "./main/stores/watch";
 import { registerStreamProtocol } from "./main/stream";
 import { registerStreamProtocol } from "./main/stream";
-import { isDev } from "./main/utils-electron";
+import { isDev } from "./main/utils/electron";
 
 
 /**
 /**
  * The URL where the renderer HTML is being served from.
  * The URL where the renderer HTML is being served from.
  */
  */
-export const rendererURL = "ente://app";
+const rendererURL = "ente://app";
 
 
 /**
 /**
  * We want to hide our window instead of closing it when the user presses the
  * We want to hide our window instead of closing it when the user presses the
@@ -130,50 +127,18 @@ const registerPrivilegedSchemes = () => {
         {
         {
             scheme: "stream",
             scheme: "stream",
             privileges: {
             privileges: {
-                // TODO(MR): Remove the commented bits if we don't end up
-                // needing them by the time the IPC refactoring is done.
-
-                // Prevent the insecure origin issues when fetching this
-                // secure: true,
-                // Allow the web fetch API in the renderer to use this scheme.
                 supportFetchAPI: true,
                 supportFetchAPI: true,
-                // Allow it to be used with video tags.
-                // stream: true,
             },
             },
         },
         },
     ]);
     ]);
 };
 };
 
 
-/**
- * [Note: Increased disk cache for the desktop app]
- *
- * Set the "disk-cache-size" command line flag to ask the Chromium process to
- * use a larger size for the caches that it keeps on disk. This allows us to use
- * the web based caching mechanisms on both the web and the desktop app, just
- * ask the embedded Chromium to be a bit more generous in disk usage when
- * running as the desktop app.
- *
- * The size we provide is in bytes.
- * https://www.electronjs.org/docs/latest/api/command-line-switches#--disk-cache-sizesize
- *
- * Note that increasing the disk cache size does not guarantee that Chromium
- * will respect in verbatim, it uses its own heuristics atop this hint.
- * https://superuser.com/questions/378991/what-is-chrome-default-cache-size-limit/1577693#1577693
- *
- * See also: [Note: Caching files].
- */
-const increaseDiskCache = () =>
-    app.commandLine.appendSwitch(
-        "disk-cache-size",
-        `${5 * 1024 * 1024 * 1024}`, // 5 GB
-    );
-
 /**
 /**
  * Create an return the {@link BrowserWindow} that will form our app's UI.
  * Create an return the {@link BrowserWindow} that will form our app's UI.
  *
  *
  * This window will show the HTML served from {@link rendererURL}.
  * This window will show the HTML served from {@link rendererURL}.
  */
  */
-const createMainWindow = async () => {
+const createMainWindow = () => {
     // Create the main window. This'll show our web content.
     // Create the main window. This'll show our web content.
     const window = new BrowserWindow({
     const window = new BrowserWindow({
         webPreferences: {
         webPreferences: {
@@ -187,7 +152,7 @@ const createMainWindow = async () => {
         show: false,
         show: false,
     });
     });
 
 
-    const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
+    const wasAutoLaunched = autoLauncher.wasAutoLaunched();
     if (wasAutoLaunched) {
     if (wasAutoLaunched) {
         // Don't automatically show the app's window if we were auto-launched.
         // Don't automatically show the app's window if we were auto-launched.
         // On macOS, also hide the dock icon on macOS.
         // On macOS, also hide the dock icon on macOS.
@@ -201,7 +166,7 @@ const createMainWindow = async () => {
     if (isDev) window.webContents.openDevTools();
     if (isDev) window.webContents.openDevTools();
 
 
     window.webContents.on("render-process-gone", (_, details) => {
     window.webContents.on("render-process-gone", (_, details) => {
-        log.error(`render-process-gone: ${details}`);
+        log.error(`render-process-gone: ${details.reason}`);
         window.webContents.reload();
         window.webContents.reload();
     });
     });
 
 
@@ -209,7 +174,7 @@ const createMainWindow = async () => {
     //  webContents is not responding to input messages for > 30 seconds."
     //  webContents is not responding to input messages for > 30 seconds."
     window.webContents.on("unresponsive", () => {
     window.webContents.on("unresponsive", () => {
         log.error(
         log.error(
-            "Main window's webContents are unresponsive, will restart the renderer process",
+            "MainWindow's webContents are unresponsive, will restart the renderer process",
         );
         );
         window.webContents.forcefullyCrashRenderer();
         window.webContents.forcefullyCrashRenderer();
     });
     });
@@ -230,7 +195,7 @@ const createMainWindow = async () => {
     });
     });
 
 
     window.on("show", () => {
     window.on("show", () => {
-        if (process.platform == "darwin") app.dock.show();
+        if (process.platform == "darwin") void app.dock.show();
     });
     });
 
 
     // Let ipcRenderer know when mainWindow is in the foreground so that it can
     // Let ipcRenderer know when mainWindow is in the foreground so that it can
@@ -240,6 +205,58 @@ const createMainWindow = async () => {
     return window;
     return window;
 };
 };
 
 
+/**
+ * Automatically set the save path for user initiated downloads to the system's
+ * "downloads" directory instead of asking the user to select a save location.
+ */
+export const setDownloadPath = (webContents: WebContents) => {
+    webContents.session.on("will-download", (_, item) => {
+        item.setSavePath(
+            uniqueSavePath(app.getPath("downloads"), item.getFilename()),
+        );
+    });
+};
+
+const uniqueSavePath = (dirPath: string, fileName: string) => {
+    const { name, ext } = path.parse(fileName);
+
+    let savePath = path.join(dirPath, fileName);
+    let n = 1;
+    while (existsSync(savePath)) {
+        const suffixedName = [`${name}(${n})`, ext].filter((x) => x).join(".");
+        savePath = path.join(dirPath, suffixedName);
+        n++;
+    }
+    return savePath;
+};
+
+/**
+ * Allow opening external links, e.g. when the user clicks on the "Feature
+ * requests" button in the sidebar (to open our GitHub repository), or when they
+ * click the "Support" button to send an email to support.
+ *
+ * @param webContents The renderer to configure.
+ */
+export const allowExternalLinks = (webContents: WebContents) => {
+    // By default, if the user were open a link, say
+    // https://github.com/ente-io/ente/discussions, then it would open a _new_
+    // BrowserWindow within our app.
+    //
+    // This is not the behaviour we want; what we want is to ask the system to
+    // handle the link (e.g. open the URL in the default browser, or if it is a
+    // mailto: link, then open the user's mail client).
+    //
+    // Returning `action` "deny" accomplishes this.
+    webContents.setWindowOpenHandler(({ url }) => {
+        if (!url.startsWith(rendererURL)) {
+            void shell.openExternal(url);
+            return { action: "deny" };
+        } else {
+            return { action: "allow" };
+        }
+    });
+};
+
 /**
 /**
  * Add an icon for our app in the system tray.
  * Add an icon for our app in the system tray.
  *
  *
@@ -272,24 +289,24 @@ const setupTrayItem = (mainWindow: BrowserWindow) => {
  * Older versions of our app used to maintain a cache dir using the main
  * Older versions of our app used to maintain a cache dir using the main
  * process. This has been deprecated in favor of using a normal web cache.
  * process. This has been deprecated in favor of using a normal web cache.
  *
  *
- * See [Note: Increased disk cache for the desktop app]
- *
  * Delete the old cache dir if it exists. This code was added March 2024, and
  * Delete the old cache dir if it exists. This code was added March 2024, and
  * can be removed after some time once most people have upgraded to newer
  * can be removed after some time once most people have upgraded to newer
  * versions.
  * versions.
  */
  */
 const deleteLegacyDiskCacheDirIfExists = async () => {
 const deleteLegacyDiskCacheDirIfExists = async () => {
-    // The existing code was passing "cache" as a parameter to getPath. This is
-    // incorrect if we go by the types - "cache" is not a valid value for the
-    // parameter to `app.getPath`.
+    // The existing code was passing "cache" as a parameter to getPath.
     //
     //
-    // It might be an issue in the types, since at runtime it seems to work. For
-    // example, on macOS I get `~/Library/Caches`.
+    // However, "cache" is not a valid parameter to getPath. It works! (for
+    // example, on macOS I get `~/Library/Caches`), but it is intentionally not
+    // documented as part of the public API:
+    //
+    // - docs: remove "cache" from app.getPath
+    //   https://github.com/electron/electron/pull/33509
     //
     //
     // Irrespective, we replicate the original behaviour so that we get back the
     // Irrespective, we replicate the original behaviour so that we get back the
-    // same path that the old got was getting.
+    // same path that the old code was getting.
     //
     //
-    // @ts-expect-error
+    // @ts-expect-error "cache" works but is not part of the public API.
     const cacheDir = path.join(app.getPath("cache"), "ente");
     const cacheDir = path.join(app.getPath("cache"), "ente");
     if (existsSync(cacheDir)) {
     if (existsSync(cacheDir)) {
         log.info(`Removing legacy disk cache from ${cacheDir}`);
         log.info(`Removing legacy disk cache from ${cacheDir}`);
@@ -326,7 +343,6 @@ const main = () => {
     // The order of the next two calls is important
     // The order of the next two calls is important
     setupRendererServer();
     setupRendererServer();
     registerPrivilegedSchemes();
     registerPrivilegedSchemes();
-    increaseDiskCache();
     migrateLegacyWatchStoreIfNeeded();
     migrateLegacyWatchStoreIfNeeded();
 
 
     app.on("second-instance", () => {
     app.on("second-instance", () => {
@@ -341,32 +357,35 @@ const main = () => {
     // Emitted once, when Electron has finished initializing.
     // Emitted once, when Electron has finished initializing.
     //
     //
     // Note that some Electron APIs can only be used after this event occurs.
     // Note that some Electron APIs can only be used after this event occurs.
-    app.on("ready", async () => {
-        // Create window and prepare for renderer
-        mainWindow = await createMainWindow();
-        attachIPCHandlers();
-        attachFSWatchIPCHandlers(createWatcher(mainWindow));
-        registerStreamProtocol();
-        handleDownloads(mainWindow);
-        handleExternalLinks(mainWindow);
-        addAllowOriginHeader(mainWindow);
-
-        // Start loading the renderer
-        mainWindow.loadURL(rendererURL);
-
-        // Continue on with the rest of the startup sequence
-        Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
-        setupTrayItem(mainWindow);
-        if (!isDev) setupAutoUpdater(mainWindow);
-
-        try {
-            deleteLegacyDiskCacheDirIfExists();
-            deleteLegacyKeysStoreIfExists();
-        } catch (e) {
-            // Log but otherwise ignore errors during non-critical startup
-            // actions.
-            log.error("Ignoring startup error", e);
-        }
+    void app.whenReady().then(() => {
+        void (async () => {
+            // Create window and prepare for the renderer.
+            mainWindow = createMainWindow();
+            attachIPCHandlers();
+            attachFSWatchIPCHandlers(createWatcher(mainWindow));
+            registerStreamProtocol();
+
+            // Configure the renderer's environment.
+            setDownloadPath(mainWindow.webContents);
+            allowExternalLinks(mainWindow.webContents);
+
+            // Start loading the renderer.
+            void mainWindow.loadURL(rendererURL);
+
+            // Continue on with the rest of the startup sequence.
+            Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
+            setupTrayItem(mainWindow);
+            if (!isDev) setupAutoUpdater(mainWindow);
+
+            try {
+                await deleteLegacyDiskCacheDirIfExists();
+                await deleteLegacyKeysStoreIfExists();
+            } catch (e) {
+                // Log but otherwise ignore errors during non-critical startup
+                // actions.
+                log.error("Ignoring startup error", e);
+            }
+        })();
     });
     });
 
 
     // This is a macOS only event. Show our window when the user activates the
     // This is a macOS only event. Show our window when the user activates the

+ 0 - 54
desktop/src/main/dialogs.ts

@@ -1,54 +0,0 @@
-import { dialog } from "electron/main";
-import path from "node:path";
-import type { ElectronFile } from "../types/ipc";
-import { getDirFilePaths, getElectronFile } from "./services/fs";
-import { getElectronFilesFromGoogleZip } from "./services/upload";
-
-export const selectDirectory = async () => {
-    const result = await dialog.showOpenDialog({
-        properties: ["openDirectory"],
-    });
-    if (result.filePaths && result.filePaths.length > 0) {
-        return result.filePaths[0]?.split(path.sep)?.join(path.posix.sep);
-    }
-};
-
-export const showUploadFilesDialog = async () => {
-    const selectedFiles = await dialog.showOpenDialog({
-        properties: ["openFile", "multiSelections"],
-    });
-    const filePaths = selectedFiles.filePaths;
-    return await Promise.all(filePaths.map(getElectronFile));
-};
-
-export const showUploadDirsDialog = async () => {
-    const dir = await dialog.showOpenDialog({
-        properties: ["openDirectory", "multiSelections"],
-    });
-
-    let filePaths: string[] = [];
-    for (const dirPath of dir.filePaths) {
-        filePaths = [...filePaths, ...(await getDirFilePaths(dirPath))];
-    }
-
-    return await Promise.all(filePaths.map(getElectronFile));
-};
-
-export const showUploadZipDialog = async () => {
-    const selectedFiles = await dialog.showOpenDialog({
-        properties: ["openFile", "multiSelections"],
-        filters: [{ name: "Zip File", extensions: ["zip"] }],
-    });
-    const filePaths = selectedFiles.filePaths;
-
-    let files: ElectronFile[] = [];
-
-    for (const filePath of filePaths) {
-        files = [...files, ...(await getElectronFilesFromGoogleZip(filePath))];
-    }
-
-    return {
-        zipPaths: filePaths,
-        files,
-    };
-};

+ 0 - 31
desktop/src/main/fs.ts

@@ -1,31 +0,0 @@
-/**
- * @file file system related functions exposed over the context bridge.
- */
-import { existsSync } from "node:fs";
-import fs from "node:fs/promises";
-
-export const fsExists = (path: string) => existsSync(path);
-
-export const fsRename = (oldPath: string, newPath: string) =>
-    fs.rename(oldPath, newPath);
-
-export const fsMkdirIfNeeded = (dirPath: string) =>
-    fs.mkdir(dirPath, { recursive: true });
-
-export const fsRmdir = (path: string) => fs.rmdir(path);
-
-export const fsRm = (path: string) => fs.rm(path);
-
-export const fsReadTextFile = async (filePath: string) =>
-    fs.readFile(filePath, "utf-8");
-
-export const fsWriteFile = (path: string, contents: string) =>
-    fs.writeFile(path, contents);
-
-export const fsIsDir = async (dirPath: string) => {
-    if (!existsSync(dirPath)) return false;
-    const stat = await fs.stat(dirPath);
-    return stat.isDirectory();
-};
-
-export const fsSize = (path: string) => fs.stat(path).then((s) => s.size);

+ 0 - 63
desktop/src/main/init.ts

@@ -1,63 +0,0 @@
-import { BrowserWindow, app, shell } from "electron";
-import { existsSync } from "node:fs";
-import path from "node:path";
-import { rendererURL } from "../main";
-
-export function handleDownloads(mainWindow: BrowserWindow) {
-    mainWindow.webContents.session.on("will-download", (_, item) => {
-        item.setSavePath(
-            getUniqueSavePath(item.getFilename(), app.getPath("downloads")),
-        );
-    });
-}
-
-function getUniqueSavePath(filename: string, directory: string): string {
-    let uniqueFileSavePath = path.join(directory, filename);
-    const { name: filenameWithoutExtension, ext: extension } =
-        path.parse(filename);
-    let n = 0;
-    while (existsSync(uniqueFileSavePath)) {
-        n++;
-        // filter need to remove undefined extension from the array
-        // else [`${fileName}`, undefined].join(".") will lead to `${fileName}.` as joined string
-        const fileNameWithNumberedSuffix = [
-            `${filenameWithoutExtension}(${n})`,
-            extension,
-        ]
-            .filter((x) => x) // filters out undefined/null values
-            .join("");
-        uniqueFileSavePath = path.join(directory, fileNameWithNumberedSuffix);
-    }
-    return uniqueFileSavePath;
-}
-
-export function handleExternalLinks(mainWindow: BrowserWindow) {
-    mainWindow.webContents.setWindowOpenHandler(({ url }) => {
-        if (!url.startsWith(rendererURL)) {
-            shell.openExternal(url);
-            return { action: "deny" };
-        } else {
-            return { action: "allow" };
-        }
-    });
-}
-
-export function addAllowOriginHeader(mainWindow: BrowserWindow) {
-    mainWindow.webContents.session.webRequest.onHeadersReceived(
-        (details, callback) => {
-            details.responseHeaders = lowerCaseHeaders(details.responseHeaders);
-            details.responseHeaders["access-control-allow-origin"] = ["*"];
-            callback({
-                responseHeaders: details.responseHeaders,
-            });
-        },
-    );
-}
-
-function lowerCaseHeaders(responseHeaders: Record<string, string[]>) {
-    const headers: Record<string, string[]> = {};
-    for (const key of Object.keys(responseHeaders)) {
-        headers[key.toLowerCase()] = responseHeaders[key];
-    }
-    return headers;
-}

+ 60 - 51
desktop/src/main/ipc.ts

@@ -14,13 +14,21 @@ import type {
     CollectionMapping,
     CollectionMapping,
     FolderWatch,
     FolderWatch,
     PendingUploads,
     PendingUploads,
+    ZipItem,
 } from "../types/ipc";
 } from "../types/ipc";
+import { logToDisk } from "./log";
+import {
+    appVersion,
+    skipAppUpdate,
+    updateAndRestart,
+    updateOnNextRestart,
+} from "./services/app-update";
 import {
 import {
+    openDirectory,
+    openLogDirectory,
     selectDirectory,
     selectDirectory,
-    showUploadDirsDialog,
-    showUploadFilesDialog,
-    showUploadZipDialog,
-} from "./dialogs";
+} from "./services/dir";
+import { ffmpegExec } from "./services/ffmpeg";
 import {
 import {
     fsExists,
     fsExists,
     fsIsDir,
     fsIsDir,
@@ -29,18 +37,8 @@ import {
     fsRename,
     fsRename,
     fsRm,
     fsRm,
     fsRmdir,
     fsRmdir,
-    fsSize,
     fsWriteFile,
     fsWriteFile,
-} from "./fs";
-import { logToDisk } from "./log";
-import {
-    appVersion,
-    skipAppUpdate,
-    updateAndRestart,
-    updateOnNextRestart,
-} from "./services/app-update";
-import { ffmpegExec } from "./services/ffmpeg";
-import { getDirFiles } from "./services/fs";
+} from "./services/fs";
 import { convertToJPEG, generateImageThumbnail } from "./services/image";
 import { convertToJPEG, generateImageThumbnail } from "./services/image";
 import {
 import {
     clipImageEmbedding,
     clipImageEmbedding,
@@ -53,20 +51,23 @@ import {
     saveEncryptionKey,
     saveEncryptionKey,
 } from "./services/store";
 } from "./services/store";
 import {
 import {
-    getElectronFilesFromGoogleZip,
+    clearPendingUploads,
+    listZipItems,
+    markUploadedFiles,
+    markUploadedZipItems,
+    pathOrZipItemSize,
     pendingUploads,
     pendingUploads,
-    setPendingUploadCollection,
-    setPendingUploadFiles,
+    setPendingUploads,
 } from "./services/upload";
 } from "./services/upload";
 import {
 import {
     watchAdd,
     watchAdd,
     watchFindFiles,
     watchFindFiles,
     watchGet,
     watchGet,
     watchRemove,
     watchRemove,
+    watchReset,
     watchUpdateIgnoredFiles,
     watchUpdateIgnoredFiles,
     watchUpdateSyncedFiles,
     watchUpdateSyncedFiles,
 } from "./services/watch";
 } from "./services/watch";
-import { openDirectory, openLogDirectory } from "./utils-electron";
 
 
 /**
 /**
  * Listen for IPC events sent/invoked by the renderer process, and route them to
  * Listen for IPC events sent/invoked by the renderer process, and route them to
@@ -93,16 +94,20 @@ export const attachIPCHandlers = () => {
 
 
     ipcMain.handle("appVersion", () => appVersion());
     ipcMain.handle("appVersion", () => appVersion());
 
 
-    ipcMain.handle("openDirectory", (_, dirPath) => openDirectory(dirPath));
+    ipcMain.handle("openDirectory", (_, dirPath: string) =>
+        openDirectory(dirPath),
+    );
 
 
     ipcMain.handle("openLogDirectory", () => openLogDirectory());
     ipcMain.handle("openLogDirectory", () => openLogDirectory());
 
 
     // See [Note: Catching exception during .send/.on]
     // See [Note: Catching exception during .send/.on]
-    ipcMain.on("logToDisk", (_, message) => logToDisk(message));
+    ipcMain.on("logToDisk", (_, message: string) => logToDisk(message));
+
+    ipcMain.handle("selectDirectory", () => selectDirectory());
 
 
     ipcMain.on("clearStores", () => clearStores());
     ipcMain.on("clearStores", () => clearStores());
 
 
-    ipcMain.handle("saveEncryptionKey", (_, encryptionKey) =>
+    ipcMain.handle("saveEncryptionKey", (_, encryptionKey: string) =>
         saveEncryptionKey(encryptionKey),
         saveEncryptionKey(encryptionKey),
     );
     );
 
 
@@ -112,21 +117,23 @@ export const attachIPCHandlers = () => {
 
 
     ipcMain.on("updateAndRestart", () => updateAndRestart());
     ipcMain.on("updateAndRestart", () => updateAndRestart());
 
 
-    ipcMain.on("updateOnNextRestart", (_, version) =>
+    ipcMain.on("updateOnNextRestart", (_, version: string) =>
         updateOnNextRestart(version),
         updateOnNextRestart(version),
     );
     );
 
 
-    ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version));
+    ipcMain.on("skipAppUpdate", (_, version: string) => skipAppUpdate(version));
 
 
     // - FS
     // - FS
 
 
-    ipcMain.handle("fsExists", (_, path) => fsExists(path));
+    ipcMain.handle("fsExists", (_, path: string) => fsExists(path));
 
 
     ipcMain.handle("fsRename", (_, oldPath: string, newPath: string) =>
     ipcMain.handle("fsRename", (_, oldPath: string, newPath: string) =>
         fsRename(oldPath, newPath),
         fsRename(oldPath, newPath),
     );
     );
 
 
-    ipcMain.handle("fsMkdirIfNeeded", (_, dirPath) => fsMkdirIfNeeded(dirPath));
+    ipcMain.handle("fsMkdirIfNeeded", (_, dirPath: string) =>
+        fsMkdirIfNeeded(dirPath),
+    );
 
 
     ipcMain.handle("fsRmdir", (_, path: string) => fsRmdir(path));
     ipcMain.handle("fsRmdir", (_, path: string) => fsRmdir(path));
 
 
@@ -140,8 +147,6 @@ export const attachIPCHandlers = () => {
 
 
     ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath));
     ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath));
 
 
-    ipcMain.handle("fsSize", (_, path: string) => fsSize(path));
-
     // - Conversion
     // - Conversion
 
 
     ipcMain.handle("convertToJPEG", (_, imageData: Uint8Array) =>
     ipcMain.handle("convertToJPEG", (_, imageData: Uint8Array) =>
@@ -152,10 +157,10 @@ export const attachIPCHandlers = () => {
         "generateImageThumbnail",
         "generateImageThumbnail",
         (
         (
             _,
             _,
-            dataOrPath: Uint8Array | string,
+            dataOrPathOrZipItem: Uint8Array | string | ZipItem,
             maxDimension: number,
             maxDimension: number,
             maxSize: number,
             maxSize: number,
-        ) => generateImageThumbnail(dataOrPath, maxDimension, maxSize),
+        ) => generateImageThumbnail(dataOrPathOrZipItem, maxDimension, maxSize),
     );
     );
 
 
     ipcMain.handle(
     ipcMain.handle(
@@ -163,10 +168,16 @@ export const attachIPCHandlers = () => {
         (
         (
             _,
             _,
             command: string[],
             command: string[],
-            dataOrPath: Uint8Array | string,
+            dataOrPathOrZipItem: Uint8Array | string | ZipItem,
             outputFileExtension: string,
             outputFileExtension: string,
             timeoutMS: number,
             timeoutMS: number,
-        ) => ffmpegExec(command, dataOrPath, outputFileExtension, timeoutMS),
+        ) =>
+            ffmpegExec(
+                command,
+                dataOrPathOrZipItem,
+                outputFileExtension,
+                timeoutMS,
+            ),
     );
     );
 
 
     // - ML
     // - ML
@@ -187,37 +198,33 @@ export const attachIPCHandlers = () => {
         faceEmbedding(input),
         faceEmbedding(input),
     );
     );
 
 
-    // - File selection
-
-    ipcMain.handle("selectDirectory", () => selectDirectory());
-
-    ipcMain.handle("showUploadFilesDialog", () => showUploadFilesDialog());
-
-    ipcMain.handle("showUploadDirsDialog", () => showUploadDirsDialog());
+    // - Upload
 
 
-    ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog());
+    ipcMain.handle("listZipItems", (_, zipPath: string) =>
+        listZipItems(zipPath),
+    );
 
 
-    // - Upload
+    ipcMain.handle("pathOrZipItemSize", (_, pathOrZipItem: string | ZipItem) =>
+        pathOrZipItemSize(pathOrZipItem),
+    );
 
 
     ipcMain.handle("pendingUploads", () => pendingUploads());
     ipcMain.handle("pendingUploads", () => pendingUploads());
 
 
-    ipcMain.handle("setPendingUploadCollection", (_, collectionName: string) =>
-        setPendingUploadCollection(collectionName),
+    ipcMain.handle("setPendingUploads", (_, pendingUploads: PendingUploads) =>
+        setPendingUploads(pendingUploads),
     );
     );
 
 
     ipcMain.handle(
     ipcMain.handle(
-        "setPendingUploadFiles",
-        (_, type: PendingUploads["type"], filePaths: string[]) =>
-            setPendingUploadFiles(type, filePaths),
+        "markUploadedFiles",
+        (_, paths: PendingUploads["filePaths"]) => markUploadedFiles(paths),
     );
     );
 
 
-    // -
-
-    ipcMain.handle("getElectronFilesFromGoogleZip", (_, filePath: string) =>
-        getElectronFilesFromGoogleZip(filePath),
+    ipcMain.handle(
+        "markUploadedZipItems",
+        (_, items: PendingUploads["zipItems"]) => markUploadedZipItems(items),
     );
     );
 
 
-    ipcMain.handle("getDirFiles", (_, dirPath: string) => getDirFiles(dirPath));
+    ipcMain.handle("clearPendingUploads", () => clearPendingUploads());
 };
 };
 
 
 /**
 /**
@@ -257,4 +264,6 @@ export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
     ipcMain.handle("watchFindFiles", (_, folderPath: string) =>
     ipcMain.handle("watchFindFiles", (_, folderPath: string) =>
         watchFindFiles(folderPath),
         watchFindFiles(folderPath),
     );
     );
+
+    ipcMain.handle("watchReset", () => watchReset(watcher));
 };
 };

+ 6 - 6
desktop/src/main/log.ts

@@ -1,15 +1,15 @@
 import log from "electron-log";
 import log from "electron-log";
 import util from "node:util";
 import util from "node:util";
-import { isDev } from "./utils-electron";
+import { isDev } from "./utils/electron";
 
 
 /**
 /**
  * Initialize logging in the main process.
  * Initialize logging in the main process.
  *
  *
  * This will set our underlying logger up to log to a file named `ente.log`,
  * This will set our underlying logger up to log to a file named `ente.log`,
  *
  *
- * - on Linux at ~/.config/ente/logs/main.log
- * - on macOS at ~/Library/Logs/ente/main.log
- * - on Windows at %USERPROFILE%\AppData\Roaming\ente\logs\main.log
+ * - on Linux at ~/.config/ente/logs/ente.log
+ * - on macOS at ~/Library/Logs/ente/ente.log
+ * - on Windows at %USERPROFILE%\AppData\Roaming\ente\logs\ente.log
  *
  *
  * On dev builds, it will also log to the console.
  * On dev builds, it will also log to the console.
  */
  */
@@ -65,7 +65,7 @@ const logError_ = (message: string) => {
     if (isDev) console.error(`[error] ${message}`);
     if (isDev) console.error(`[error] ${message}`);
 };
 };
 
 
-const logInfo = (...params: any[]) => {
+const logInfo = (...params: unknown[]) => {
     const message = params
     const message = params
         .map((p) => (typeof p == "string" ? p : util.inspect(p)))
         .map((p) => (typeof p == "string" ? p : util.inspect(p)))
         .join(" ");
         .join(" ");
@@ -73,7 +73,7 @@ const logInfo = (...params: any[]) => {
     if (isDev) console.log(`[info] ${message}`);
     if (isDev) console.log(`[info] ${message}`);
 };
 };
 
 
-const logDebug = (param: () => any) => {
+const logDebug = (param: () => unknown) => {
     if (isDev) {
     if (isDev) {
         const p = param();
         const p = param();
         console.log(`[debug] ${typeof p == "string" ? p : util.inspect(p)}`);
         console.log(`[debug] ${typeof p == "string" ? p : util.inspect(p)}`);

+ 11 - 8
desktop/src/main/menu.ts

@@ -8,8 +8,9 @@ import {
 import { allowWindowClose } from "../main";
 import { allowWindowClose } from "../main";
 import { forceCheckForAppUpdates } from "./services/app-update";
 import { forceCheckForAppUpdates } from "./services/app-update";
 import autoLauncher from "./services/auto-launcher";
 import autoLauncher from "./services/auto-launcher";
+import { openLogDirectory } from "./services/dir";
 import { userPreferences } from "./stores/user-preferences";
 import { userPreferences } from "./stores/user-preferences";
-import { isDev, openLogDirectory } from "./utils-electron";
+import { isDev } from "./utils/electron";
 
 
 /** Create and return the entries in the app's main menu bar */
 /** Create and return the entries in the app's main menu bar */
 export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
 export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
@@ -18,7 +19,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
     // Whenever the menu is redrawn the current value of these variables is used
     // Whenever the menu is redrawn the current value of these variables is used
     // to set the checked state for the various settings checkboxes.
     // to set the checked state for the various settings checkboxes.
     let isAutoLaunchEnabled = await autoLauncher.isEnabled();
     let isAutoLaunchEnabled = await autoLauncher.isEnabled();
-    let shouldHideDockIcon = userPreferences.get("hideDockIcon");
+    let shouldHideDockIcon = !!userPreferences.get("hideDockIcon");
 
 
     const macOSOnly = (options: MenuItemConstructorOptions[]) =>
     const macOSOnly = (options: MenuItemConstructorOptions[]) =>
         process.platform == "darwin" ? options : [];
         process.platform == "darwin" ? options : [];
@@ -29,12 +30,12 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
     const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
     const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
 
 
     const handleViewChangelog = () =>
     const handleViewChangelog = () =>
-        shell.openExternal(
+        void shell.openExternal(
             "https://github.com/ente-io/ente/blob/main/desktop/CHANGELOG.md",
             "https://github.com/ente-io/ente/blob/main/desktop/CHANGELOG.md",
         );
         );
 
 
     const toggleAutoLaunch = () => {
     const toggleAutoLaunch = () => {
-        autoLauncher.toggleAutoLaunch();
+        void autoLauncher.toggleAutoLaunch();
         isAutoLaunchEnabled = !isAutoLaunchEnabled;
         isAutoLaunchEnabled = !isAutoLaunchEnabled;
     };
     };
 
 
@@ -45,13 +46,15 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
         shouldHideDockIcon = !shouldHideDockIcon;
         shouldHideDockIcon = !shouldHideDockIcon;
     };
     };
 
 
-    const handleHelp = () => shell.openExternal("https://help.ente.io/photos/");
+    const handleHelp = () =>
+        void shell.openExternal("https://help.ente.io/photos/");
 
 
-    const handleSupport = () => shell.openExternal("mailto:support@ente.io");
+    const handleSupport = () =>
+        void shell.openExternal("mailto:support@ente.io");
 
 
-    const handleBlog = () => shell.openExternal("https://ente.io/blog/");
+    const handleBlog = () => void shell.openExternal("https://ente.io/blog/");
 
 
-    const handleViewLogs = openLogDirectory;
+    const handleViewLogs = () => void openLogDirectory();
 
 
     return Menu.buildFromTemplate([
     return Menu.buildFromTemplate([
         {
         {

+ 9 - 6
desktop/src/main/services/app-update.ts

@@ -12,8 +12,8 @@ export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
     autoUpdater.autoDownload = false;
     autoUpdater.autoDownload = false;
 
 
     const oneDay = 1 * 24 * 60 * 60 * 1000;
     const oneDay = 1 * 24 * 60 * 60 * 1000;
-    setInterval(() => checkForUpdatesAndNotify(mainWindow), oneDay);
-    checkForUpdatesAndNotify(mainWindow);
+    setInterval(() => void checkForUpdatesAndNotify(mainWindow), oneDay);
+    void checkForUpdatesAndNotify(mainWindow);
 };
 };
 
 
 /**
 /**
@@ -22,7 +22,7 @@ export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
 export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => {
 export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => {
     userPreferences.delete("skipAppVersion");
     userPreferences.delete("skipAppVersion");
     userPreferences.delete("muteUpdateNotificationVersion");
     userPreferences.delete("muteUpdateNotificationVersion");
-    checkForUpdatesAndNotify(mainWindow);
+    void checkForUpdatesAndNotify(mainWindow);
 };
 };
 
 
 const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
 const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
@@ -36,18 +36,21 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
 
 
     log.debug(() => `Update check found version ${version}`);
     log.debug(() => `Update check found version ${version}`);
 
 
+    if (!version)
+        throw new Error("Unexpected empty version obtained from auto-updater");
+
     if (compareVersions(version, app.getVersion()) <= 0) {
     if (compareVersions(version, app.getVersion()) <= 0) {
         log.debug(() => "Skipping update, already at latest version");
         log.debug(() => "Skipping update, already at latest version");
         return;
         return;
     }
     }
 
 
-    if (version === userPreferences.get("skipAppVersion")) {
+    if (version == userPreferences.get("skipAppVersion")) {
         log.info(`User chose to skip version ${version}`);
         log.info(`User chose to skip version ${version}`);
         return;
         return;
     }
     }
 
 
     const mutedVersion = userPreferences.get("muteUpdateNotificationVersion");
     const mutedVersion = userPreferences.get("muteUpdateNotificationVersion");
-    if (version === mutedVersion) {
+    if (version == mutedVersion) {
         log.info(`User has muted update notifications for version ${version}`);
         log.info(`User has muted update notifications for version ${version}`);
         return;
         return;
     }
     }
@@ -56,7 +59,7 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
         mainWindow.webContents.send("appUpdateAvailable", update);
         mainWindow.webContents.send("appUpdateAvailable", update);
 
 
     log.debug(() => "Attempting auto update");
     log.debug(() => "Attempting auto update");
-    autoUpdater.downloadUpdate();
+    await autoUpdater.downloadUpdate();
 
 
     let timeoutId: ReturnType<typeof setTimeout>;
     let timeoutId: ReturnType<typeof setTimeout>;
     const fiveMinutes = 5 * 60 * 1000;
     const fiveMinutes = 5 * 60 * 1000;

+ 1 - 1
desktop/src/main/services/auto-launcher.ts

@@ -38,7 +38,7 @@ class AutoLauncher {
         }
         }
     }
     }
 
 
-    async wasAutoLaunched() {
+    wasAutoLaunched() {
         if (this.autoLaunch) {
         if (this.autoLaunch) {
             return app.commandLine.hasSwitch("hidden");
             return app.commandLine.hasSwitch("hidden");
         } else {
         } else {

+ 51 - 0
desktop/src/main/services/dir.ts

@@ -0,0 +1,51 @@
+import { shell } from "electron/common";
+import { app, dialog } from "electron/main";
+import path from "node:path";
+import { posixPath } from "../utils/electron";
+
+export const selectDirectory = async () => {
+    const result = await dialog.showOpenDialog({
+        properties: ["openDirectory"],
+    });
+    const dirPath = result.filePaths[0];
+    return dirPath ? posixPath(dirPath) : undefined;
+};
+
+/**
+ * Open the given {@link dirPath} in the system's folder viewer.
+ *
+ * For example, on macOS this'll open {@link dirPath} in Finder.
+ */
+export const openDirectory = async (dirPath: string) => {
+    // We need to use `path.normalize` because `shell.openPath; does not support
+    // POSIX path, it needs to be a platform specific path:
+    // https://github.com/electron/electron/issues/28831#issuecomment-826370589
+    const res = await shell.openPath(path.normalize(dirPath));
+    // `shell.openPath` resolves with a string containing the error message
+    // corresponding to the failure if a failure occurred, otherwise "".
+    if (res) throw new Error(`Failed to open directory ${dirPath}: res`);
+};
+
+/**
+ * Open the app's log directory in the system's folder viewer.
+ *
+ * @see {@link openDirectory}
+ */
+export const openLogDirectory = () => openDirectory(logDirectoryPath());
+
+/**
+ * Return the path where the logs for the app are saved.
+ *
+ * [Note: Electron app paths]
+ *
+ * By default, these paths are at the following locations:
+ *
+ * - macOS: `~/Library/Application Support/ente`
+ * - Linux: `~/.config/ente`
+ * - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local\ente`
+ * - Windows: C:\Users\<you>\AppData\Local\<Your App Name>
+ *
+ * https://www.electronjs.org/docs/latest/api/app
+ *
+ */
+const logDirectoryPath = () => app.getPath("logs");

+ 20 - 19
desktop/src/main/services/ffmpeg.ts

@@ -1,9 +1,14 @@
 import pathToFfmpeg from "ffmpeg-static";
 import pathToFfmpeg from "ffmpeg-static";
 import fs from "node:fs/promises";
 import fs from "node:fs/promises";
+import type { ZipItem } from "../../types/ipc";
 import log from "../log";
 import log from "../log";
-import { withTimeout } from "../utils";
-import { execAsync } from "../utils-electron";
-import { deleteTempFile, makeTempFilePath } from "../utils-temp";
+import { ensure, withTimeout } from "../utils/common";
+import { execAsync } from "../utils/electron";
+import {
+    deleteTempFile,
+    makeFileForDataOrPathOrZipItem,
+    makeTempFilePath,
+} from "../utils/temp";
 
 
 /* Duplicated in the web app's code (used by the WASM FFmpeg implementation). */
 /* Duplicated in the web app's code (used by the WASM FFmpeg implementation). */
 const ffmpegPathPlaceholder = "FFMPEG";
 const ffmpegPathPlaceholder = "FFMPEG";
@@ -39,28 +44,24 @@ const outputPathPlaceholder = "OUTPUT";
  */
  */
 export const ffmpegExec = async (
 export const ffmpegExec = async (
     command: string[],
     command: string[],
-    dataOrPath: Uint8Array | string,
+    dataOrPathOrZipItem: Uint8Array | string | ZipItem,
     outputFileExtension: string,
     outputFileExtension: string,
     timeoutMS: number,
     timeoutMS: number,
 ): Promise<Uint8Array> => {
 ): Promise<Uint8Array> => {
-    // TODO (MR): This currently copies files for both input and output. This
-    // needs to be tested extremely large video files when invoked downstream of
-    // `convertToMP4` in the web code.
+    // TODO (MR): This currently copies files for both input (when
+    // dataOrPathOrZipItem is data) and output. This needs to be tested
+    // extremely large video files when invoked downstream of `convertToMP4` in
+    // the web code.
 
 
-    let inputFilePath: string;
-    let isInputFileTemporary: boolean;
-    if (dataOrPath instanceof Uint8Array) {
-        inputFilePath = await makeTempFilePath();
-        isInputFileTemporary = true;
-    } else {
-        inputFilePath = dataOrPath;
-        isInputFileTemporary = false;
-    }
+    const {
+        path: inputFilePath,
+        isFileTemporary: isInputFileTemporary,
+        writeToTemporaryFile: writeToTemporaryInputFile,
+    } = await makeFileForDataOrPathOrZipItem(dataOrPathOrZipItem);
 
 
     const outputFilePath = await makeTempFilePath(outputFileExtension);
     const outputFilePath = await makeTempFilePath(outputFileExtension);
     try {
     try {
-        if (dataOrPath instanceof Uint8Array)
-            await fs.writeFile(inputFilePath, dataOrPath);
+        await writeToTemporaryInputFile();
 
 
         const cmd = substitutePlaceholders(
         const cmd = substitutePlaceholders(
             command,
             command,
@@ -109,5 +110,5 @@ const ffmpegBinaryPath = () => {
     // This substitution of app.asar by app.asar.unpacked is suggested by the
     // This substitution of app.asar by app.asar.unpacked is suggested by the
     // ffmpeg-static library author themselves:
     // ffmpeg-static library author themselves:
     // https://github.com/eugeneware/ffmpeg-static/issues/16
     // https://github.com/eugeneware/ffmpeg-static/issues/16
-    return pathToFfmpeg.replace("app.asar", "app.asar.unpacked");
+    return ensure(pathToFfmpeg).replace("app.asar", "app.asar.unpacked");
 };
 };

+ 19 - 166
desktop/src/main/services/fs.ts

@@ -1,177 +1,30 @@
-import StreamZip from "node-stream-zip";
+/**
+ * @file file system related functions exposed over the context bridge.
+ */
+
 import { existsSync } from "node:fs";
 import { existsSync } from "node:fs";
 import fs from "node:fs/promises";
 import fs from "node:fs/promises";
-import path from "node:path";
-import { ElectronFile } from "../../types/ipc";
-import log from "../log";
-
-const FILE_STREAM_CHUNK_SIZE: number = 4 * 1024 * 1024;
-
-export async function getDirFiles(dirPath: string) {
-    const files = await getDirFilePaths(dirPath);
-    const electronFiles = await Promise.all(files.map(getElectronFile));
-    return electronFiles;
-}
-
-// https://stackoverflow.com/a/63111390
-export const getDirFilePaths = async (dirPath: string) => {
-    if (!(await fs.stat(dirPath)).isDirectory()) {
-        return [dirPath];
-    }
 
 
-    let files: string[] = [];
-    const filePaths = await fs.readdir(dirPath);
+export const fsExists = (path: string) => existsSync(path);
 
 
-    for (const filePath of filePaths) {
-        const absolute = path.join(dirPath, filePath);
-        files = [...files, ...(await getDirFilePaths(absolute))];
-    }
-
-    return files;
-};
-
-const getFileStream = async (filePath: string) => {
-    const file = await fs.open(filePath, "r");
-    let offset = 0;
-    const readableStream = new ReadableStream<Uint8Array>({
-        async pull(controller) {
-            try {
-                const buff = new Uint8Array(FILE_STREAM_CHUNK_SIZE);
-                const bytesRead = (await file.read(
-                    buff,
-                    0,
-                    FILE_STREAM_CHUNK_SIZE,
-                    offset,
-                )) as unknown as number;
-                offset += bytesRead;
-                if (bytesRead === 0) {
-                    controller.close();
-                    await file.close();
-                } else {
-                    controller.enqueue(buff.slice(0, bytesRead));
-                }
-            } catch (e) {
-                await file.close();
-            }
-        },
-        async cancel() {
-            await file.close();
-        },
-    });
-    return readableStream;
-};
+export const fsRename = (oldPath: string, newPath: string) =>
+    fs.rename(oldPath, newPath);
 
 
-export async function getElectronFile(filePath: string): Promise<ElectronFile> {
-    const fileStats = await fs.stat(filePath);
-    return {
-        path: filePath.split(path.sep).join(path.posix.sep),
-        name: path.basename(filePath),
-        size: fileStats.size,
-        lastModified: fileStats.mtime.valueOf(),
-        stream: async () => {
-            if (!existsSync(filePath)) {
-                throw new Error("electronFile does not exist");
-            }
-            return await getFileStream(filePath);
-        },
-        blob: async () => {
-            if (!existsSync(filePath)) {
-                throw new Error("electronFile does not exist");
-            }
-            const blob = await fs.readFile(filePath);
-            return new Blob([new Uint8Array(blob)]);
-        },
-        arrayBuffer: async () => {
-            if (!existsSync(filePath)) {
-                throw new Error("electronFile does not exist");
-            }
-            const blob = await fs.readFile(filePath);
-            return new Uint8Array(blob);
-        },
-    };
-}
+export const fsMkdirIfNeeded = (dirPath: string) =>
+    fs.mkdir(dirPath, { recursive: true });
 
 
-export const getZipFileStream = async (
-    zip: StreamZip.StreamZipAsync,
-    filePath: string,
-) => {
-    const stream = await zip.stream(filePath);
-    const done = {
-        current: false,
-    };
-    const inProgress = {
-        current: false,
-    };
-    // eslint-disable-next-line no-unused-vars
-    let resolveObj: (value?: any) => void = null;
-    // eslint-disable-next-line no-unused-vars
-    let rejectObj: (reason?: any) => void = null;
-    stream.on("readable", () => {
-        try {
-            if (resolveObj) {
-                inProgress.current = true;
-                const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
-                if (chunk) {
-                    resolveObj(new Uint8Array(chunk));
-                    resolveObj = null;
-                }
-                inProgress.current = false;
-            }
-        } catch (e) {
-            rejectObj(e);
-        }
-    });
-    stream.on("end", () => {
-        try {
-            done.current = true;
-            if (resolveObj && !inProgress.current) {
-                resolveObj(null);
-                resolveObj = null;
-            }
-        } catch (e) {
-            rejectObj(e);
-        }
-    });
-    stream.on("error", (e) => {
-        try {
-            done.current = true;
-            if (rejectObj) {
-                rejectObj(e);
-                rejectObj = null;
-            }
-        } catch (e) {
-            rejectObj(e);
-        }
-    });
+export const fsRmdir = (path: string) => fs.rmdir(path);
 
 
-    const readStreamData = async () => {
-        return new Promise<Uint8Array>((resolve, reject) => {
-            const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
+export const fsRm = (path: string) => fs.rm(path);
 
 
-            if (chunk || done.current) {
-                resolve(chunk);
-            } else {
-                resolveObj = resolve;
-                rejectObj = reject;
-            }
-        });
-    };
+export const fsReadTextFile = async (filePath: string) =>
+    fs.readFile(filePath, "utf-8");
 
 
-    const readableStream = new ReadableStream<Uint8Array>({
-        async pull(controller) {
-            try {
-                const data = await readStreamData();
+export const fsWriteFile = (path: string, contents: string) =>
+    fs.writeFile(path, contents);
 
 
-                if (data) {
-                    controller.enqueue(data);
-                } else {
-                    controller.close();
-                }
-            } catch (e) {
-                log.error("Failed to pull from readableStream", e);
-                controller.close();
-            }
-        },
-    });
-    return readableStream;
+export const fsIsDir = async (dirPath: string) => {
+    if (!existsSync(dirPath)) return false;
+    const stat = await fs.stat(dirPath);
+    return stat.isDirectory();
 };
 };

+ 15 - 16
desktop/src/main/services/image.ts

@@ -1,11 +1,15 @@
 /** @file Image format conversions and thumbnail generation */
 /** @file Image format conversions and thumbnail generation */
 
 
 import fs from "node:fs/promises";
 import fs from "node:fs/promises";
-import path from "path";
-import { CustomErrorMessage } from "../../types/ipc";
+import path from "node:path";
+import { CustomErrorMessage, type ZipItem } from "../../types/ipc";
 import log from "../log";
 import log from "../log";
-import { execAsync, isDev } from "../utils-electron";
-import { deleteTempFile, makeTempFilePath } from "../utils-temp";
+import { execAsync, isDev } from "../utils/electron";
+import {
+    deleteTempFile,
+    makeFileForDataOrPathOrZipItem,
+    makeTempFilePath,
+} from "../utils/temp";
 
 
 export const convertToJPEG = async (imageData: Uint8Array) => {
 export const convertToJPEG = async (imageData: Uint8Array) => {
     const inputFilePath = await makeTempFilePath();
     const inputFilePath = await makeTempFilePath();
@@ -63,19 +67,15 @@ const imageMagickPath = () =>
     path.join(isDev ? "build" : process.resourcesPath, "image-magick");
     path.join(isDev ? "build" : process.resourcesPath, "image-magick");
 
 
 export const generateImageThumbnail = async (
 export const generateImageThumbnail = async (
-    dataOrPath: Uint8Array | string,
+    dataOrPathOrZipItem: Uint8Array | string | ZipItem,
     maxDimension: number,
     maxDimension: number,
     maxSize: number,
     maxSize: number,
 ): Promise<Uint8Array> => {
 ): Promise<Uint8Array> => {
-    let inputFilePath: string;
-    let isInputFileTemporary: boolean;
-    if (dataOrPath instanceof Uint8Array) {
-        inputFilePath = await makeTempFilePath();
-        isInputFileTemporary = true;
-    } else {
-        inputFilePath = dataOrPath;
-        isInputFileTemporary = false;
-    }
+    const {
+        path: inputFilePath,
+        isFileTemporary: isInputFileTemporary,
+        writeToTemporaryFile: writeToTemporaryInputFile,
+    } = await makeFileForDataOrPathOrZipItem(dataOrPathOrZipItem);
 
 
     const outputFilePath = await makeTempFilePath("jpeg");
     const outputFilePath = await makeTempFilePath("jpeg");
 
 
@@ -89,8 +89,7 @@ export const generateImageThumbnail = async (
     );
     );
 
 
     try {
     try {
-        if (dataOrPath instanceof Uint8Array)
-            await fs.writeFile(inputFilePath, dataOrPath);
+        await writeToTemporaryInputFile();
 
 
         let thumbnail: Uint8Array;
         let thumbnail: Uint8Array;
         do {
         do {

+ 46 - 48
desktop/src/main/services/ml-clip.ts

@@ -11,7 +11,8 @@ import * as ort from "onnxruntime-node";
 import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
 import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
 import log from "../log";
 import log from "../log";
 import { writeStream } from "../stream";
 import { writeStream } from "../stream";
-import { deleteTempFile, makeTempFilePath } from "../utils-temp";
+import { ensure } from "../utils/common";
+import { deleteTempFile, makeTempFilePath } from "../utils/temp";
 import { makeCachedInferenceSession } from "./ml";
 import { makeCachedInferenceSession } from "./ml";
 
 
 const cachedCLIPImageSession = makeCachedInferenceSession(
 const cachedCLIPImageSession = makeCachedInferenceSession(
@@ -22,7 +23,7 @@ const cachedCLIPImageSession = makeCachedInferenceSession(
 export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
 export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
     const tempFilePath = await makeTempFilePath();
     const tempFilePath = await makeTempFilePath();
     const imageStream = new Response(jpegImageData.buffer).body;
     const imageStream = new Response(jpegImageData.buffer).body;
-    await writeStream(tempFilePath, imageStream);
+    await writeStream(tempFilePath, ensure(imageStream));
     try {
     try {
         return await clipImageEmbedding_(tempFilePath);
         return await clipImageEmbedding_(tempFilePath);
     } finally {
     } finally {
@@ -44,30 +45,30 @@ const clipImageEmbedding_ = async (jpegFilePath: string) => {
             `onnx/clip image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
             `onnx/clip image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
     );
     );
     /* Need these model specific casts to type the result */
     /* Need these model specific casts to type the result */
-    const imageEmbedding = results["output"].data as Float32Array;
+    const imageEmbedding = ensure(results.output).data as Float32Array;
     return normalizeEmbedding(imageEmbedding);
     return normalizeEmbedding(imageEmbedding);
 };
 };
 
 
-const getRGBData = async (jpegFilePath: string) => {
+const getRGBData = async (jpegFilePath: string): Promise<number[]> => {
     const jpegData = await fs.readFile(jpegFilePath);
     const jpegData = await fs.readFile(jpegFilePath);
     const rawImageData = jpeg.decode(jpegData, {
     const rawImageData = jpeg.decode(jpegData, {
         useTArray: true,
         useTArray: true,
         formatAsRGBA: false,
         formatAsRGBA: false,
     });
     });
 
 
-    const nx: number = rawImageData.width;
-    const ny: number = rawImageData.height;
-    const inputImage: Uint8Array = rawImageData.data;
+    const nx = rawImageData.width;
+    const ny = rawImageData.height;
+    const inputImage = rawImageData.data;
 
 
-    const nx2: number = 224;
-    const ny2: number = 224;
-    const totalSize: number = 3 * nx2 * ny2;
+    const nx2 = 224;
+    const ny2 = 224;
+    const totalSize = 3 * nx2 * ny2;
 
 
-    const result: number[] = Array(totalSize).fill(0);
-    const scale: number = Math.max(nx, ny) / 224;
+    const result = Array<number>(totalSize).fill(0);
+    const scale = Math.max(nx, ny) / 224;
 
 
-    const nx3: number = Math.round(nx / scale);
-    const ny3: number = Math.round(ny / scale);
+    const nx3 = Math.round(nx / scale);
+    const ny3 = Math.round(ny / scale);
 
 
     const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
     const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
     const std: number[] = [0.26862954, 0.26130258, 0.27577711];
     const std: number[] = [0.26862954, 0.26130258, 0.27577711];
@@ -76,40 +77,40 @@ const getRGBData = async (jpegFilePath: string) => {
         for (let x = 0; x < nx3; x++) {
         for (let x = 0; x < nx3; x++) {
             for (let c = 0; c < 3; c++) {
             for (let c = 0; c < 3; c++) {
                 // Linear interpolation
                 // Linear interpolation
-                const sx: number = (x + 0.5) * scale - 0.5;
-                const sy: number = (y + 0.5) * scale - 0.5;
+                const sx = (x + 0.5) * scale - 0.5;
+                const sy = (y + 0.5) * scale - 0.5;
 
 
-                const x0: number = Math.max(0, Math.floor(sx));
-                const y0: number = Math.max(0, Math.floor(sy));
+                const x0 = Math.max(0, Math.floor(sx));
+                const y0 = Math.max(0, Math.floor(sy));
 
 
-                const x1: number = Math.min(x0 + 1, nx - 1);
-                const y1: number = Math.min(y0 + 1, ny - 1);
+                const x1 = Math.min(x0 + 1, nx - 1);
+                const y1 = Math.min(y0 + 1, ny - 1);
 
 
-                const dx: number = sx - x0;
-                const dy: number = sy - y0;
+                const dx = sx - x0;
+                const dy = sy - y0;
 
 
-                const j00: number = 3 * (y0 * nx + x0) + c;
-                const j01: number = 3 * (y0 * nx + x1) + c;
-                const j10: number = 3 * (y1 * nx + x0) + c;
-                const j11: number = 3 * (y1 * nx + x1) + c;
+                const j00 = 3 * (y0 * nx + x0) + c;
+                const j01 = 3 * (y0 * nx + x1) + c;
+                const j10 = 3 * (y1 * nx + x0) + c;
+                const j11 = 3 * (y1 * nx + x1) + c;
 
 
-                const v00: number = inputImage[j00];
-                const v01: number = inputImage[j01];
-                const v10: number = inputImage[j10];
-                const v11: number = inputImage[j11];
+                const v00 = inputImage[j00] ?? 0;
+                const v01 = inputImage[j01] ?? 0;
+                const v10 = inputImage[j10] ?? 0;
+                const v11 = inputImage[j11] ?? 0;
 
 
-                const v0: number = v00 * (1 - dx) + v01 * dx;
-                const v1: number = v10 * (1 - dx) + v11 * dx;
+                const v0 = v00 * (1 - dx) + v01 * dx;
+                const v1 = v10 * (1 - dx) + v11 * dx;
 
 
-                const v: number = v0 * (1 - dy) + v1 * dy;
+                const v = v0 * (1 - dy) + v1 * dy;
 
 
-                const v2: number = Math.min(Math.max(Math.round(v), 0), 255);
+                const v2 = Math.min(Math.max(Math.round(v), 0), 255);
 
 
                 // createTensorWithDataList is dumb compared to reshape and
                 // createTensorWithDataList is dumb compared to reshape and
                 // hence has to be given with one channel after another
                 // hence has to be given with one channel after another
-                const i: number = y * nx3 + x + (c % 3) * 224 * 224;
+                const i = y * nx3 + x + (c % 3) * 224 * 224;
 
 
-                result[i] = (v2 / 255 - mean[c]) / std[c];
+                result[i] = (v2 / 255 - (mean[c] ?? 0)) / (std[c] ?? 1);
             }
             }
         }
         }
     }
     }
@@ -119,13 +120,12 @@ const getRGBData = async (jpegFilePath: string) => {
 
 
 const normalizeEmbedding = (embedding: Float32Array) => {
 const normalizeEmbedding = (embedding: Float32Array) => {
     let normalization = 0;
     let normalization = 0;
-    for (let index = 0; index < embedding.length; index++) {
-        normalization += embedding[index] * embedding[index];
-    }
+    for (const v of embedding) normalization += v * v;
+
     const sqrtNormalization = Math.sqrt(normalization);
     const sqrtNormalization = Math.sqrt(normalization);
-    for (let index = 0; index < embedding.length; index++) {
-        embedding[index] = embedding[index] / sqrtNormalization;
-    }
+    for (let index = 0; index < embedding.length; index++)
+        embedding[index] = ensure(embedding[index]) / sqrtNormalization;
+
     return embedding;
     return embedding;
 };
 };
 
 
@@ -134,11 +134,9 @@ const cachedCLIPTextSession = makeCachedInferenceSession(
     64173509 /* 61.2 MB */,
     64173509 /* 61.2 MB */,
 );
 );
 
 
-let _tokenizer: Tokenizer = null;
+let _tokenizer: Tokenizer | undefined;
 const getTokenizer = () => {
 const getTokenizer = () => {
-    if (!_tokenizer) {
-        _tokenizer = new Tokenizer();
-    }
+    if (!_tokenizer) _tokenizer = new Tokenizer();
     return _tokenizer;
     return _tokenizer;
 };
 };
 
 
@@ -150,7 +148,7 @@ export const clipTextEmbeddingIfAvailable = async (text: string) => {
 
 
     // Don't wait for the download to complete
     // Don't wait for the download to complete
     if (typeof sessionOrStatus == "string") {
     if (typeof sessionOrStatus == "string") {
-        console.log(
+        log.info(
             "Ignoring CLIP text embedding request because model download is pending",
             "Ignoring CLIP text embedding request because model download is pending",
         );
         );
         return undefined;
         return undefined;
@@ -169,6 +167,6 @@ export const clipTextEmbeddingIfAvailable = async (text: string) => {
         () =>
         () =>
             `onnx/clip text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
             `onnx/clip text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
     );
     );
-    const textEmbedding = results["output"].data as Float32Array;
+    const textEmbedding = ensure(results.output).data as Float32Array;
     return normalizeEmbedding(textEmbedding);
     return normalizeEmbedding(textEmbedding);
 };
 };

+ 4 - 2
desktop/src/main/services/ml-face.ts

@@ -8,6 +8,7 @@
  */
  */
 import * as ort from "onnxruntime-node";
 import * as ort from "onnxruntime-node";
 import log from "../log";
 import log from "../log";
+import { ensure } from "../utils/common";
 import { makeCachedInferenceSession } from "./ml";
 import { makeCachedInferenceSession } from "./ml";
 
 
 const cachedFaceDetectionSession = makeCachedInferenceSession(
 const cachedFaceDetectionSession = makeCachedInferenceSession(
@@ -23,7 +24,7 @@ export const detectFaces = async (input: Float32Array) => {
     };
     };
     const results = await session.run(feeds);
     const results = await session.run(feeds);
     log.debug(() => `onnx/yolo face detection took ${Date.now() - t} ms`);
     log.debug(() => `onnx/yolo face detection took ${Date.now() - t} ms`);
-    return results["output"].data;
+    return ensure(results.output).data;
 };
 };
 
 
 const cachedFaceEmbeddingSession = makeCachedInferenceSession(
 const cachedFaceEmbeddingSession = makeCachedInferenceSession(
@@ -46,5 +47,6 @@ export const faceEmbedding = async (input: Float32Array) => {
     const results = await session.run(feeds);
     const results = await session.run(feeds);
     log.debug(() => `onnx/yolo face embedding took ${Date.now() - t} ms`);
     log.debug(() => `onnx/yolo face embedding took ${Date.now() - t} ms`);
     /* Need these model specific casts to extract and type the result */
     /* Need these model specific casts to extract and type the result */
-    return (results.embeddings as unknown as any)["cpuData"] as Float32Array;
+    return (results.embeddings as unknown as Record<string, unknown>)
+        .cpuData as Float32Array;
 };
 };

+ 8 - 5
desktop/src/main/services/ml.ts

@@ -34,6 +34,7 @@ import { writeStream } from "../stream";
  * actively trigger a download until the returned function is called.
  * actively trigger a download until the returned function is called.
  *
  *
  * @param modelName The name of the model to download.
  * @param modelName The name of the model to download.
+ *
  * @param modelByteSize The size in bytes that we expect the model to have. If
  * @param modelByteSize The size in bytes that we expect the model to have. If
  * the size of the downloaded model does not match the expected size, then we
  * the size of the downloaded model does not match the expected size, then we
  * will redownload it.
  * will redownload it.
@@ -99,13 +100,15 @@ const downloadModel = async (saveLocation: string, name: string) => {
     // `mkdir -p` the directory where we want to save the model.
     // `mkdir -p` the directory where we want to save the model.
     const saveDir = path.dirname(saveLocation);
     const saveDir = path.dirname(saveLocation);
     await fs.mkdir(saveDir, { recursive: true });
     await fs.mkdir(saveDir, { recursive: true });
-    // Download
+    // Download.
     log.info(`Downloading ML model from ${name}`);
     log.info(`Downloading ML model from ${name}`);
     const url = `https://models.ente.io/${name}`;
     const url = `https://models.ente.io/${name}`;
     const res = await net.fetch(url);
     const res = await net.fetch(url);
     if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
     if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
-    // Save
-    await writeStream(saveLocation, res.body);
+    const body = res.body;
+    if (!body) throw new Error(`Received an null response for ${url}`);
+    // Save.
+    await writeStream(saveLocation, body);
     log.info(`Downloaded CLIP model ${name}`);
     log.info(`Downloaded CLIP model ${name}`);
 };
 };
 
 
@@ -114,9 +117,9 @@ const downloadModel = async (saveLocation: string, name: string) => {
  */
  */
 const createInferenceSession = async (modelPath: string) => {
 const createInferenceSession = async (modelPath: string) => {
     return await ort.InferenceSession.create(modelPath, {
     return await ort.InferenceSession.create(modelPath, {
-        // Restrict the number of threads to 1
+        // Restrict the number of threads to 1.
         intraOpNumThreads: 1,
         intraOpNumThreads: 1,
-        // Be more conservative with RAM usage
+        // Be more conservative with RAM usage.
         enableCpuMemArena: false,
         enableCpuMemArena: false,
     });
     });
 };
 };

+ 5 - 5
desktop/src/main/services/store.ts

@@ -9,20 +9,20 @@ import { watchStore } from "../stores/watch";
  * This is useful to reset state when the user logs out.
  * This is useful to reset state when the user logs out.
  */
  */
 export const clearStores = () => {
 export const clearStores = () => {
-    uploadStatusStore.clear();
     safeStorageStore.clear();
     safeStorageStore.clear();
+    uploadStatusStore.clear();
     watchStore.clear();
     watchStore.clear();
 };
 };
 
 
-export const saveEncryptionKey = async (encryptionKey: string) => {
-    const encryptedKey: Buffer = await safeStorage.encryptString(encryptionKey);
+export const saveEncryptionKey = (encryptionKey: string) => {
+    const encryptedKey = safeStorage.encryptString(encryptionKey);
     const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
     const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
     safeStorageStore.set("encryptionKey", b64EncryptedKey);
     safeStorageStore.set("encryptionKey", b64EncryptedKey);
 };
 };
 
 
-export const encryptionKey = async (): Promise<string | undefined> => {
+export const encryptionKey = (): string | undefined => {
     const b64EncryptedKey = safeStorageStore.get("encryptionKey");
     const b64EncryptedKey = safeStorageStore.get("encryptionKey");
     if (!b64EncryptedKey) return undefined;
     if (!b64EncryptedKey) return undefined;
     const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
     const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
-    return await safeStorage.decryptString(keyBuffer);
+    return safeStorage.decryptString(keyBuffer);
 };
 };

+ 130 - 97
desktop/src/main/services/upload.ts

@@ -1,116 +1,149 @@
 import StreamZip from "node-stream-zip";
 import StreamZip from "node-stream-zip";
+import fs from "node:fs/promises";
+import path from "node:path";
 import { existsSync } from "original-fs";
 import { existsSync } from "original-fs";
-import path from "path";
-import { ElectronFile, type PendingUploads } from "../../types/ipc";
-import {
-    uploadStatusStore,
-    type UploadStatusStore,
-} from "../stores/upload-status";
-import { getElectronFile, getZipFileStream } from "./fs";
-
-export const pendingUploads = async () => {
-    const collectionName = uploadStatusStore.get("collectionName");
-    const filePaths = validSavedPaths("files");
-    const zipPaths = validSavedPaths("zips");
-
-    let files: ElectronFile[] = [];
-    let type: PendingUploads["type"];
-
-    if (zipPaths.length) {
-        type = "zips";
-        for (const zipPath of zipPaths) {
-            files = [
-                ...files,
-                ...(await getElectronFilesFromGoogleZip(zipPath)),
-            ];
+import type { PendingUploads, ZipItem } from "../../types/ipc";
+import { uploadStatusStore } from "../stores/upload-status";
+
+export const listZipItems = async (zipPath: string): Promise<ZipItem[]> => {
+    const zip = new StreamZip.async({ file: zipPath });
+
+    const entries = await zip.entries();
+    const entryNames: string[] = [];
+
+    for (const entry of Object.values(entries)) {
+        const basename = path.basename(entry.name);
+        // Ignore "hidden" files (files whose names begins with a dot).
+        if (entry.isFile && !basename.startsWith(".")) {
+            // `entry.name` is the path within the zip.
+            entryNames.push(entry.name);
         }
         }
-        const pendingFilePaths = new Set(filePaths);
-        files = files.filter((file) => pendingFilePaths.has(file.path));
-    } else if (filePaths.length) {
-        type = "files";
-        files = await Promise.all(filePaths.map(getElectronFile));
     }
     }
 
 
-    return {
-        files,
-        collectionName,
-        type,
-    };
-};
+    await zip.close();
 
 
-export const validSavedPaths = (type: PendingUploads["type"]) => {
-    const key = storeKey(type);
-    const savedPaths = (uploadStatusStore.get(key) as string[]) ?? [];
-    const paths = savedPaths.filter((p) => existsSync(p));
-    uploadStatusStore.set(key, paths);
-    return paths;
+    return entryNames.map((entryName) => [zipPath, entryName]);
 };
 };
 
 
-export const setPendingUploadCollection = (collectionName: string) => {
-    if (collectionName) uploadStatusStore.set("collectionName", collectionName);
-    else uploadStatusStore.delete("collectionName");
+export const pathOrZipItemSize = async (
+    pathOrZipItem: string | ZipItem,
+): Promise<number> => {
+    if (typeof pathOrZipItem == "string") {
+        const stat = await fs.stat(pathOrZipItem);
+        return stat.size;
+    } else {
+        const [zipPath, entryName] = pathOrZipItem;
+        const zip = new StreamZip.async({ file: zipPath });
+        const entry = await zip.entry(entryName);
+        if (!entry)
+            throw new Error(
+                `An entry with name ${entryName} does not exist in the zip file at ${zipPath}`,
+            );
+        const size = entry.size;
+        await zip.close();
+        return size;
+    }
 };
 };
 
 
-export const setPendingUploadFiles = (
-    type: PendingUploads["type"],
-    filePaths: string[],
-) => {
-    const key = storeKey(type);
-    if (filePaths) uploadStatusStore.set(key, filePaths);
-    else uploadStatusStore.delete(key);
-};
+export const pendingUploads = async (): Promise<PendingUploads | undefined> => {
+    const collectionName = uploadStatusStore.get("collectionName") ?? undefined;
+
+    const allFilePaths = uploadStatusStore.get("filePaths") ?? [];
+    const filePaths = allFilePaths.filter((f) => existsSync(f));
 
 
-const storeKey = (type: PendingUploads["type"]): keyof UploadStatusStore => {
-    switch (type) {
-        case "zips":
-            return "zipPaths";
-        case "files":
-            return "filePaths";
+    const allZipItems = uploadStatusStore.get("zipItems");
+    let zipItems: typeof allZipItems;
+
+    // Migration code - May 2024. Remove after a bit.
+    //
+    // The older store formats will not have zipItems and instead will have
+    // zipPaths. If we find such a case, read the zipPaths and enqueue all of
+    // their files as zipItems in the result.
+    //
+    // This potentially can be cause us to try reuploading an already uploaded
+    // file, but the dedup logic will kick in at that point so no harm will come
+    // of it.
+    if (allZipItems === undefined) {
+        const allZipPaths = uploadStatusStore.get("filePaths") ?? [];
+        const zipPaths = allZipPaths.filter((f) => existsSync(f));
+        zipItems = [];
+        for (const zip of zipPaths)
+            zipItems = zipItems.concat(await listZipItems(zip));
+    } else {
+        zipItems = allZipItems.filter(([z]) => existsSync(z));
     }
     }
+
+    if (filePaths.length == 0 && zipItems.length == 0) return undefined;
+
+    return {
+        collectionName,
+        filePaths,
+        zipItems,
+    };
 };
 };
 
 
-export const getElectronFilesFromGoogleZip = async (filePath: string) => {
-    const zip = new StreamZip.async({
-        file: filePath,
+/**
+ * [Note: Missing values in electron-store]
+ *
+ * Suppose we were to create a store like this:
+ *
+ *     const store = new Store({
+ *         schema: {
+ *             foo: { type: "string" },
+ *             bars: { type: "array", items: { type: "string" } },
+ *         },
+ *     });
+ *
+ * If we fetch `store.get("foo")` or `store.get("bars")`, we get `undefined`.
+ * But if we try to set these back to `undefined`, say `store.set("foo",
+ * someUndefValue)`, we get asked to
+ *
+ *     TypeError: Use `delete()` to clear values
+ *
+ * This happens even if we do bulk object updates, e.g. with a JS object that
+ * has undefined keys:
+ *
+ * > TypeError: Setting a value of type `undefined` for key `collectionName` is
+ * > not allowed as it's not supported by JSON
+ *
+ * So what should the TypeScript type for "foo" be?
+ *
+ * If it is were to not include the possibility of `undefined`, then the type
+ * would lie because `store.get("foo")` can indeed be `undefined. But if we were
+ * to include the possibility of `undefined`, then trying to `store.set("foo",
+ * someUndefValue)` will throw.
+ *
+ * The approach we take is to rely on false-y values (empty strings and empty
+ * arrays) to indicate missing values, and then converting those to `undefined`
+ * when reading from the store, and converting `undefined` to the corresponding
+ * false-y value when writing.
+ */
+export const setPendingUploads = ({
+    collectionName,
+    filePaths,
+    zipItems,
+}: PendingUploads) => {
+    uploadStatusStore.set({
+        collectionName: collectionName ?? "",
+        filePaths: filePaths,
+        zipItems: zipItems,
     });
     });
-    const zipName = path.basename(filePath, ".zip");
-
-    const entries = await zip.entries();
-    const files: ElectronFile[] = [];
+};
 
 
-    for (const entry of Object.values(entries)) {
-        const basename = path.basename(entry.name);
-        if (entry.isFile && basename.length > 0 && basename[0] !== ".") {
-            files.push(await getZipEntryAsElectronFile(zipName, zip, entry));
-        }
-    }
+export const markUploadedFiles = (paths: string[]) => {
+    const existing = uploadStatusStore.get("filePaths") ?? [];
+    const updated = existing.filter((p) => !paths.includes(p));
+    uploadStatusStore.set("filePaths", updated);
+};
 
 
-    return files;
+export const markUploadedZipItems = (
+    items: [zipPath: string, entryName: string][],
+) => {
+    const existing = uploadStatusStore.get("zipItems") ?? [];
+    const updated = existing.filter(
+        (z) => !items.some((e) => z[0] == e[0] && z[1] == e[1]),
+    );
+    uploadStatusStore.set("zipItems", updated);
 };
 };
 
 
-export async function getZipEntryAsElectronFile(
-    zipName: string,
-    zip: StreamZip.StreamZipAsync,
-    entry: StreamZip.ZipEntry,
-): Promise<ElectronFile> {
-    return {
-        path: path
-            .join(zipName, entry.name)
-            .split(path.sep)
-            .join(path.posix.sep),
-        name: path.basename(entry.name),
-        size: entry.size,
-        lastModified: entry.time,
-        stream: async () => {
-            return await getZipFileStream(zip, entry.name);
-        },
-        blob: async () => {
-            const buffer = await zip.entryData(entry.name);
-            return new Blob([new Uint8Array(buffer)]);
-        },
-        arrayBuffer: async () => {
-            const buffer = await zip.entryData(entry.name);
-            return new Uint8Array(buffer);
-        },
-    };
-}
+export const clearPendingUploads = () => uploadStatusStore.clear();

+ 19 - 22
desktop/src/main/services/watch.ts

@@ -3,9 +3,10 @@ import { BrowserWindow } from "electron/main";
 import fs from "node:fs/promises";
 import fs from "node:fs/promises";
 import path from "node:path";
 import path from "node:path";
 import { FolderWatch, type CollectionMapping } from "../../types/ipc";
 import { FolderWatch, type CollectionMapping } from "../../types/ipc";
-import { fsIsDir } from "../fs";
 import log from "../log";
 import log from "../log";
 import { watchStore } from "../stores/watch";
 import { watchStore } from "../stores/watch";
+import { posixPath } from "../utils/electron";
+import { fsIsDir } from "./fs";
 
 
 /**
 /**
  * Create and return a new file system watcher.
  * Create and return a new file system watcher.
@@ -34,8 +35,8 @@ export const createWatcher = (mainWindow: BrowserWindow) => {
     return watcher;
     return watcher;
 };
 };
 
 
-const eventData = (path: string): [string, FolderWatch] => {
-    path = posixPath(path);
+const eventData = (platformPath: string): [string, FolderWatch] => {
+    const path = posixPath(platformPath);
 
 
     const watch = folderWatches().find((watch) =>
     const watch = folderWatches().find((watch) =>
         path.startsWith(watch.folderPath + "/"),
         path.startsWith(watch.folderPath + "/"),
@@ -46,23 +47,15 @@ const eventData = (path: string): [string, FolderWatch] => {
     return [path, watch];
     return [path, watch];
 };
 };
 
 
-/**
- * Convert a file system {@link filePath} that uses the local system specific
- * path separators into a path that uses POSIX file separators.
- */
-const posixPath = (filePath: string) =>
-    filePath.split(path.sep).join(path.posix.sep);
-
-export const watchGet = (watcher: FSWatcher) => {
-    const [valid, deleted] = folderWatches().reduce(
-        ([valid, deleted], watch) => {
-            (fsIsDir(watch.folderPath) ? valid : deleted).push(watch);
-            return [valid, deleted];
-        },
-        [[], []],
-    );
-    if (deleted.length) {
-        for (const watch of deleted) watchRemove(watcher, watch.folderPath);
+export const watchGet = async (watcher: FSWatcher): Promise<FolderWatch[]> => {
+    const valid: FolderWatch[] = [];
+    const deletedPaths: string[] = [];
+    for (const watch of folderWatches()) {
+        if (await fsIsDir(watch.folderPath)) valid.push(watch);
+        else deletedPaths.push(watch.folderPath);
+    }
+    if (deletedPaths.length) {
+        await Promise.all(deletedPaths.map((p) => watchRemove(watcher, p)));
         setFolderWatches(valid);
         setFolderWatches(valid);
     }
     }
     return valid;
     return valid;
@@ -80,7 +73,7 @@ export const watchAdd = async (
 ) => {
 ) => {
     const watches = folderWatches();
     const watches = folderWatches();
 
 
-    if (!fsIsDir(folderPath))
+    if (!(await fsIsDir(folderPath)))
         throw new Error(
         throw new Error(
             `Attempting to add a folder watch for a folder path ${folderPath} that is not an existing directory`,
             `Attempting to add a folder watch for a folder path ${folderPath} that is not an existing directory`,
         );
         );
@@ -104,7 +97,7 @@ export const watchAdd = async (
     return watches;
     return watches;
 };
 };
 
 
-export const watchRemove = async (watcher: FSWatcher, folderPath: string) => {
+export const watchRemove = (watcher: FSWatcher, folderPath: string) => {
     const watches = folderWatches();
     const watches = folderWatches();
     const filtered = watches.filter((watch) => watch.folderPath != folderPath);
     const filtered = watches.filter((watch) => watch.folderPath != folderPath);
     if (watches.length == filtered.length)
     if (watches.length == filtered.length)
@@ -157,3 +150,7 @@ export const watchFindFiles = async (dirPath: string) => {
     }
     }
     return paths;
     return paths;
 };
 };
+
+export const watchReset = (watcher: FSWatcher) => {
+    watcher.unwatch(folderWatches().map((watch) => watch.folderPath));
+};

+ 1 - 1
desktop/src/main/stores/safe-storage.ts

@@ -1,7 +1,7 @@
 import Store, { Schema } from "electron-store";
 import Store, { Schema } from "electron-store";
 
 
 interface SafeStorageStore {
 interface SafeStorageStore {
-    encryptionKey: string;
+    encryptionKey?: string;
 }
 }
 
 
 const safeStorageSchema: Schema<SafeStorageStore> = {
 const safeStorageSchema: Schema<SafeStorageStore> = {

+ 30 - 6
desktop/src/main/stores/upload-status.ts

@@ -1,27 +1,51 @@
 import Store, { Schema } from "electron-store";
 import Store, { Schema } from "electron-store";
 
 
 export interface UploadStatusStore {
 export interface UploadStatusStore {
-    filePaths: string[];
-    zipPaths: string[];
-    collectionName: string;
+    /**
+     * The collection to which we're uploading, or the root collection.
+     *
+     * Not all pending uploads will have an associated collection.
+     */
+    collectionName?: string;
+    /**
+     * Paths to regular files that are pending upload.
+     */
+    filePaths?: string[];
+    /**
+     * Each item is the path to a zip file and the name of an entry within it.
+     */
+    zipItems?: [zipPath: string, entryName: string][];
+    /**
+     * @deprecated Legacy paths to zip files, now subsumed into zipItems.
+     */
+    zipPaths?: string[];
 }
 }
 
 
 const uploadStatusSchema: Schema<UploadStatusStore> = {
 const uploadStatusSchema: Schema<UploadStatusStore> = {
+    collectionName: {
+        type: "string",
+    },
     filePaths: {
     filePaths: {
         type: "array",
         type: "array",
         items: {
         items: {
             type: "string",
             type: "string",
         },
         },
     },
     },
+    zipItems: {
+        type: "array",
+        items: {
+            type: "array",
+            items: {
+                type: "string",
+            },
+        },
+    },
     zipPaths: {
     zipPaths: {
         type: "array",
         type: "array",
         items: {
         items: {
             type: "string",
             type: "string",
         },
         },
     },
     },
-    collectionName: {
-        type: "string",
-    },
 };
 };
 
 
 export const uploadStatusStore = new Store({
 export const uploadStatusStore = new Store({

+ 1 - 1
desktop/src/main/stores/user-preferences.ts

@@ -1,7 +1,7 @@
 import Store, { Schema } from "electron-store";
 import Store, { Schema } from "electron-store";
 
 
 interface UserPreferences {
 interface UserPreferences {
-    hideDockIcon: boolean;
+    hideDockIcon?: boolean;
     skipAppVersion?: string;
     skipAppVersion?: string;
     muteUpdateNotificationVersion?: string;
     muteUpdateNotificationVersion?: string;
 }
 }

+ 9 - 5
desktop/src/main/stores/watch.ts

@@ -3,7 +3,7 @@ import { type FolderWatch } from "../../types/ipc";
 import log from "../log";
 import log from "../log";
 
 
 interface WatchStore {
 interface WatchStore {
-    mappings: FolderWatchWithLegacyFields[];
+    mappings?: FolderWatchWithLegacyFields[];
 }
 }
 
 
 type FolderWatchWithLegacyFields = FolderWatch & {
 type FolderWatchWithLegacyFields = FolderWatch & {
@@ -54,8 +54,12 @@ export const watchStore = new Store({
  */
  */
 export const migrateLegacyWatchStoreIfNeeded = () => {
 export const migrateLegacyWatchStoreIfNeeded = () => {
     let needsUpdate = false;
     let needsUpdate = false;
-    const watches = watchStore.get("mappings")?.map((watch) => {
+    const updatedWatches = [];
+    for (const watch of watchStore.get("mappings") ?? []) {
         let collectionMapping = watch.collectionMapping;
         let collectionMapping = watch.collectionMapping;
+        // The required type defines the latest schema, but before migration
+        // this'll be undefined, so tell ESLint to calm down.
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
         if (!collectionMapping) {
         if (!collectionMapping) {
             collectionMapping = watch.uploadStrategy == 1 ? "parent" : "root";
             collectionMapping = watch.uploadStrategy == 1 ? "parent" : "root";
             needsUpdate = true;
             needsUpdate = true;
@@ -64,10 +68,10 @@ export const migrateLegacyWatchStoreIfNeeded = () => {
             delete watch.rootFolderName;
             delete watch.rootFolderName;
             needsUpdate = true;
             needsUpdate = true;
         }
         }
-        return { ...watch, collectionMapping };
-    });
+        updatedWatches.push({ ...watch, collectionMapping });
+    }
     if (needsUpdate) {
     if (needsUpdate) {
-        watchStore.set("mappings", watches);
+        watchStore.set("mappings", updatedWatches);
         log.info("Migrated legacy watch store data to new schema");
         log.info("Migrated legacy watch store data to new schema");
     }
     }
 };
 };

+ 83 - 57
desktop/src/main/stream.ts

@@ -2,11 +2,14 @@
  * @file stream data to-from renderer using a custom protocol handler.
  * @file stream data to-from renderer using a custom protocol handler.
  */
  */
 import { net, protocol } from "electron/main";
 import { net, protocol } from "electron/main";
+import StreamZip from "node-stream-zip";
 import { createWriteStream, existsSync } from "node:fs";
 import { createWriteStream, existsSync } from "node:fs";
 import fs from "node:fs/promises";
 import fs from "node:fs/promises";
 import { Readable } from "node:stream";
 import { Readable } from "node:stream";
+import { ReadableStream } from "node:stream/web";
 import { pathToFileURL } from "node:url";
 import { pathToFileURL } from "node:url";
 import log from "./log";
 import log from "./log";
+import { ensure } from "./utils/common";
 
 
 /**
 /**
  * Register a protocol handler that we use for streaming large files between the
  * Register a protocol handler that we use for streaming large files between the
@@ -34,19 +37,18 @@ export const registerStreamProtocol = () => {
     protocol.handle("stream", async (request: Request) => {
     protocol.handle("stream", async (request: Request) => {
         const url = request.url;
         const url = request.url;
         // The request URL contains the command to run as the host, and the
         // The request URL contains the command to run as the host, and the
-        // pathname of the file as the path. For example,
-        //
-        //     stream://write/path/to/file
-        //              host-pathname-----
-        //
-        const { host, pathname } = new URL(url);
-        // Convert e.g. "%20" to spaces.
-        const path = decodeURIComponent(pathname);
+        // pathname of the file(s) as the search params.
+        const { host, searchParams } = new URL(url);
         switch (host) {
         switch (host) {
             case "read":
             case "read":
-                return handleRead(path);
+                return handleRead(ensure(searchParams.get("path")));
+            case "read-zip":
+                return handleReadZip(
+                    ensure(searchParams.get("zipPath")),
+                    ensure(searchParams.get("entryName")),
+                );
             case "write":
             case "write":
-                return handleWrite(path, request);
+                return handleWrite(ensure(searchParams.get("path")), request);
             default:
             default:
                 return new Response("", { status: 404 });
                 return new Response("", { status: 404 });
         }
         }
@@ -57,10 +59,17 @@ const handleRead = async (path: string) => {
     try {
     try {
         const res = await net.fetch(pathToFileURL(path).toString());
         const res = await net.fetch(pathToFileURL(path).toString());
         if (res.ok) {
         if (res.ok) {
-            // net.fetch defaults to text/plain, which might be fine
-            // in practice, but as an extra precaution indicate that
-            // this is binary data.
-            res.headers.set("Content-Type", "application/octet-stream");
+            // net.fetch already seems to add "Content-Type" and "Last-Modified"
+            // headers, but I couldn't find documentation for this. In any case,
+            // since we already are stat-ting the file for the "Content-Length",
+            // we explicitly add the "X-Last-Modified-Ms" too,
+            //
+            // 1. Guaranteeing its presence,
+            //
+            // 2. Having it be in the exact format we want (no string <-> date
+            //    conversions),
+            //
+            // 3. Retaining milliseconds.
 
 
             const stat = await fs.stat(path);
             const stat = await fs.stat(path);
 
 
@@ -75,7 +84,54 @@ const handleRead = async (path: string) => {
         return res;
         return res;
     } catch (e) {
     } catch (e) {
         log.error(`Failed to read stream at ${path}`, e);
         log.error(`Failed to read stream at ${path}`, e);
-        return new Response(`Failed to read stream: ${e.message}`, {
+        return new Response(`Failed to read stream: ${String(e)}`, {
+            status: 500,
+        });
+    }
+};
+
+const handleReadZip = async (zipPath: string, entryName: string) => {
+    try {
+        const zip = new StreamZip.async({ file: zipPath });
+        const entry = await zip.entry(entryName);
+        if (!entry) return new Response("", { status: 404 });
+
+        // This returns an "old style" NodeJS.ReadableStream.
+        const stream = await zip.stream(entry);
+        // Convert it into a new style NodeJS.Readable.
+        const nodeReadable = new Readable().wrap(stream);
+        // Then convert it into a Web stream.
+        const webReadableStreamAny = Readable.toWeb(nodeReadable);
+        // However, we get a ReadableStream<any> now. This doesn't go into the
+        // `BodyInit` expected by the Response constructor, which wants a
+        // ReadableStream<Uint8Array>. Force a cast.
+        const webReadableStream =
+            webReadableStreamAny as ReadableStream<Uint8Array>;
+
+        // Close the zip handle when the underlying stream closes.
+        stream.on("end", () => void zip.close());
+
+        return new Response(webReadableStream, {
+            headers: {
+                // We don't know the exact type, but it doesn't really matter,
+                // just set it to a generic binary content-type so that the
+                // browser doesn't tinker with it thinking of it as text.
+                "Content-Type": "application/octet-stream",
+                "Content-Length": `${entry.size}`,
+                // While it is documented that entry.time is the modification
+                // time, the units are not mentioned. By seeing the source code,
+                // we can verify that it is indeed epoch milliseconds. See
+                // `parseZipTime` in the node-stream-zip source,
+                // https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js
+                "X-Last-Modified-Ms": `${entry.time}`,
+            },
+        });
+    } catch (e) {
+        log.error(
+            `Failed to read entry ${entryName} from zip file at ${zipPath}`,
+            e,
+        );
+        return new Response(`Failed to read stream: ${String(e)}`, {
             status: 500,
             status: 500,
         });
         });
     }
     }
@@ -83,11 +139,11 @@ const handleRead = async (path: string) => {
 
 
 const handleWrite = async (path: string, request: Request) => {
 const handleWrite = async (path: string, request: Request) => {
     try {
     try {
-        await writeStream(path, request.body);
+        await writeStream(path, ensure(request.body));
         return new Response("", { status: 200 });
         return new Response("", { status: 200 });
     } catch (e) {
     } catch (e) {
         log.error(`Failed to write stream to ${path}`, e);
         log.error(`Failed to write stream to ${path}`, e);
-        return new Response(`Failed to write stream: ${e.message}`, {
+        return new Response(`Failed to write stream: ${String(e)}`, {
             status: 500,
             status: 500,
         });
         });
     }
     }
@@ -99,59 +155,29 @@ const handleWrite = async (path: string, request: Request) => {
  * The returned promise resolves when the write completes.
  * The returned promise resolves when the write completes.
  *
  *
  * @param filePath The local filesystem path where the file should be written.
  * @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
+ * @param readableStream A web
+ * [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
  */
  */
-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;
-};
+export const writeStream = (filePath: string, readableStream: ReadableStream) =>
+    writeNodeStream(filePath, Readable.fromWeb(readableStream));
 
 
-const writeNodeStream = async (
-    filePath: string,
-    fileStream: NodeJS.ReadableStream,
-) => {
+const writeNodeStream = async (filePath: string, fileStream: Readable) => {
     const writeable = createWriteStream(filePath);
     const writeable = createWriteStream(filePath);
 
 
-    fileStream.on("error", (error) => {
-        writeable.destroy(error); // Close the writable stream with an error
+    fileStream.on("error", (err) => {
+        writeable.destroy(err); // Close the writable stream with an error
     });
     });
 
 
     fileStream.pipe(writeable);
     fileStream.pipe(writeable);
 
 
     await new Promise((resolve, reject) => {
     await new Promise((resolve, reject) => {
         writeable.on("finish", resolve);
         writeable.on("finish", resolve);
-        writeable.on("error", async (e: unknown) => {
+        writeable.on("error", (err) => {
             if (existsSync(filePath)) {
             if (existsSync(filePath)) {
-                await fs.unlink(filePath);
+                void fs.unlink(filePath);
             }
             }
-            reject(e);
+            reject(err);
         });
         });
     });
     });
 };
 };

+ 0 - 63
desktop/src/main/utils-temp.ts

@@ -1,63 +0,0 @@
-import { app } from "electron/main";
-import { existsSync } from "node:fs";
-import fs from "node:fs/promises";
-import path from "path";
-
-/**
- * Our very own directory within the system temp directory. Go crazy, but
- * remember to clean up, especially in exception handlers.
- */
-const enteTempDirPath = async () => {
-    const result = path.join(app.getPath("temp"), "ente");
-    await fs.mkdir(result, { recursive: true });
-    return result;
-};
-
-/** Generate a random string suitable for being used as a file name prefix */
-const randomPrefix = () => {
-    const alphabet =
-        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
-
-    let result = "";
-    for (let i = 0; i < 10; i++)
-        result += alphabet[Math.floor(Math.random() * alphabet.length)];
-    return result;
-};
-
-/**
- * Return the path to a temporary file with the given {@link suffix}.
- *
- * The function returns the path to a file in the system temp directory (in an
- * Ente specific folder therin) with a random prefix and an (optional)
- * {@link extension}.
- *
- * It ensures that there is no existing item with the same name already.
- *
- * Use {@link deleteTempFile} to remove this file when you're done.
- */
-export const makeTempFilePath = async (extension?: string) => {
-    const tempDir = await enteTempDirPath();
-    const suffix = extension ? "." + extension : "";
-    let result: string;
-    do {
-        result = path.join(tempDir, randomPrefix() + suffix);
-    } while (existsSync(result));
-    return result;
-};
-
-/**
- * Delete a temporary file at the given path if it exists.
- *
- * This is the same as a vanilla {@link fs.rm}, except it first checks that the
- * given path is within the Ente specific directory in the system temp
- * directory. This acts as an additional safety check.
- *
- * @param tempFilePath The path to the temporary file to delete. This path
- * should've been previously created using {@link makeTempFilePath}.
- */
-export const deleteTempFile = async (tempFilePath: string) => {
-    const tempDir = await enteTempDirPath();
-    if (!tempFilePath.startsWith(tempDir))
-        throw new Error(`Attempting to delete a non-temp file ${tempFilePath}`);
-    await fs.rm(tempFilePath, { force: true });
-};

+ 12 - 3
desktop/src/main/utils.ts → desktop/src/main/utils/common.ts

@@ -1,10 +1,19 @@
 /**
 /**
- * @file grab bag of utitity functions.
+ * @file grab bag of utility functions.
  *
  *
- * Many of these are verbatim copies of functions from web code since there
- * isn't currently a common package that both of them share.
+ * These are verbatim copies of functions from web code since there isn't
+ * currently a common package that both of them share.
  */
  */
 
 
+/**
+ * Throw an exception if the given value is `null` or `undefined`.
+ */
+export const ensure = <T>(v: T | null | undefined): T => {
+    if (v === null) throw new Error("Required value was null");
+    if (v === undefined) throw new Error("Required value was not found");
+    return v;
+};
+
 /**
 /**
  * Wait for {@link ms} milliseconds
  * Wait for {@link ms} milliseconds
  *
  *

+ 24 - 41
desktop/src/main/utils-electron.ts → desktop/src/main/utils/electron.ts

@@ -1,14 +1,35 @@
 import shellescape from "any-shell-escape";
 import shellescape from "any-shell-escape";
-import { shell } from "electron"; /* TODO(MR): Why is this not in /main? */
 import { app } from "electron/main";
 import { app } from "electron/main";
 import { exec } from "node:child_process";
 import { exec } from "node:child_process";
 import path from "node:path";
 import path from "node:path";
 import { promisify } from "node:util";
 import { promisify } from "node:util";
-import log from "./log";
+import log from "../log";
 
 
 /** `true` if the app is running in development mode. */
 /** `true` if the app is running in development mode. */
 export const isDev = !app.isPackaged;
 export const isDev = !app.isPackaged;
 
 
+/**
+ * Convert a file system {@link platformPath} that uses the local system
+ * specific path separators into a path that uses POSIX file separators.
+ *
+ * For all paths that we persist or pass over the IPC boundary, we always use
+ * POSIX paths, even on Windows.
+ *
+ * Windows recognizes both forward and backslashes. This also works with drive
+ * names. c:\foo\bar and c:/foo/bar are both valid.
+ *
+ * > Almost all paths passed to Windows APIs are normalized. During
+ * > normalization, Windows performs the following steps: ... All forward
+ * > slashes (/) are converted into the standard Windows separator, the back
+ * > slash (\).
+ * >
+ * > https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats
+ */
+export const posixPath = (platformPath: string) =>
+    path.sep == path.posix.sep
+        ? platformPath
+        : platformPath.split(path.sep).join(path.posix.sep);
+
 /**
 /**
  * Run a shell command asynchronously.
  * Run a shell command asynchronously.
  *
  *
@@ -33,49 +54,11 @@ export const execAsync = (command: string | string[]) => {
         ? shellescape(command)
         ? shellescape(command)
         : command;
         : command;
     const startTime = Date.now();
     const startTime = Date.now();
-    log.debug(() => `Running shell command: ${escapedCommand}`);
     const result = execAsync_(escapedCommand);
     const result = execAsync_(escapedCommand);
     log.debug(
     log.debug(
-        () =>
-            `Completed in ${Math.round(Date.now() - startTime)} ms (${escapedCommand})`,
+        () => `${escapedCommand} (${Math.round(Date.now() - startTime)} ms)`,
     );
     );
     return result;
     return result;
 };
 };
 
 
 const execAsync_ = promisify(exec);
 const execAsync_ = promisify(exec);
-
-/**
- * Open the given {@link dirPath} in the system's folder viewer.
- *
- * For example, on macOS this'll open {@link dirPath} in Finder.
- */
-export const openDirectory = async (dirPath: string) => {
-    const res = await shell.openPath(path.normalize(dirPath));
-    // shell.openPath resolves with a string containing the error message
-    // corresponding to the failure if a failure occurred, otherwise "".
-    if (res) throw new Error(`Failed to open directory ${dirPath}: res`);
-};
-
-/**
- * Open the app's log directory in the system's folder viewer.
- *
- * @see {@link openDirectory}
- */
-export const openLogDirectory = () => openDirectory(logDirectoryPath());
-
-/**
- * Return the path where the logs for the app are saved.
- *
- * [Note: Electron app paths]
- *
- * By default, these paths are at the following locations:
- *
- * - macOS: `~/Library/Application Support/ente`
- * - Linux: `~/.config/ente`
- * - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local\ente`
- * - Windows: C:\Users\<you>\AppData\Local\<Your App Name>
- *
- * https://www.electronjs.org/docs/latest/api/app
- *
- */
-const logDirectoryPath = () => app.getPath("logs");

+ 125 - 0
desktop/src/main/utils/temp.ts

@@ -0,0 +1,125 @@
+import { app } from "electron/main";
+import StreamZip from "node-stream-zip";
+import { existsSync } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import type { ZipItem } from "../../types/ipc";
+import { ensure } from "./common";
+
+/**
+ * Our very own directory within the system temp directory. Go crazy, but
+ * remember to clean up, especially in exception handlers.
+ */
+const enteTempDirPath = async () => {
+    const result = path.join(app.getPath("temp"), "ente");
+    await fs.mkdir(result, { recursive: true });
+    return result;
+};
+
+/** Generate a random string suitable for being used as a file name prefix */
+const randomPrefix = () => {
+    const ch = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+    const randomChar = () => ensure(ch[Math.floor(Math.random() * ch.length)]);
+
+    return Array(10).fill("").map(randomChar).join("");
+};
+
+/**
+ * Return the path to a temporary file with the given {@link suffix}.
+ *
+ * The function returns the path to a file in the system temp directory (in an
+ * Ente specific folder therin) with a random prefix and an (optional)
+ * {@link extension}.
+ *
+ * It ensures that there is no existing item with the same name already.
+ *
+ * Use {@link deleteTempFile} to remove this file when you're done.
+ */
+export const makeTempFilePath = async (extension?: string) => {
+    const tempDir = await enteTempDirPath();
+    const suffix = extension ? "." + extension : "";
+    let result: string;
+    do {
+        result = path.join(tempDir, randomPrefix() + suffix);
+    } while (existsSync(result));
+    return result;
+};
+
+/**
+ * Delete a temporary file at the given path if it exists.
+ *
+ * This is the same as a vanilla {@link fs.rm}, except it first checks that the
+ * given path is within the Ente specific directory in the system temp
+ * directory. This acts as an additional safety check.
+ *
+ * @param tempFilePath The path to the temporary file to delete. This path
+ * should've been previously created using {@link makeTempFilePath}.
+ */
+export const deleteTempFile = async (tempFilePath: string) => {
+    const tempDir = await enteTempDirPath();
+    if (!tempFilePath.startsWith(tempDir))
+        throw new Error(`Attempting to delete a non-temp file ${tempFilePath}`);
+    await fs.rm(tempFilePath, { force: true });
+};
+
+/** The result of {@link makeFileForDataOrPathOrZipItem}. */
+interface FileForDataOrPathOrZipItem {
+    /**
+     * The path to the file (possibly temporary).
+     */
+    path: string;
+    /**
+     * `true` if {@link path} points to a temporary file which should be deleted
+     * once we are done processing.
+     */
+    isFileTemporary: boolean;
+    /**
+     * A function that can be called to actually write the contents of the
+     * source `Uint8Array | string | ZipItem` into the file at {@link path}.
+     *
+     * It will do nothing in the case when the source is already a path. In the
+     * other two cases this function will write the data or zip item into the
+     * file at {@link path}.
+     */
+    writeToTemporaryFile: () => Promise<void>;
+}
+
+/**
+ * Return the path to a file, a boolean indicating if this is a temporary path
+ * that needs to be deleted after processing, and a function to write the given
+ * {@link dataOrPathOrZipItem} into that temporary file if needed.
+ *
+ * @param dataOrPathOrZipItem The contents of the file, or the path to an
+ * existing file, or a (path to a zip file, name of an entry within that zip
+ * file) tuple.
+ */
+export const makeFileForDataOrPathOrZipItem = async (
+    dataOrPathOrZipItem: Uint8Array | string | ZipItem,
+): Promise<FileForDataOrPathOrZipItem> => {
+    let path: string;
+    let isFileTemporary: boolean;
+    let writeToTemporaryFile = async () => {
+        /* no-op */
+    };
+
+    if (typeof dataOrPathOrZipItem == "string") {
+        path = dataOrPathOrZipItem;
+        isFileTemporary = false;
+    } else {
+        path = await makeTempFilePath();
+        isFileTemporary = true;
+        if (dataOrPathOrZipItem instanceof Uint8Array) {
+            writeToTemporaryFile = () =>
+                fs.writeFile(path, dataOrPathOrZipItem);
+        } else {
+            writeToTemporaryFile = async () => {
+                const [zipPath, entryName] = dataOrPathOrZipItem;
+                const zip = new StreamZip.async({ file: zipPath });
+                await zip.extract(entryName, path);
+                await zip.close();
+            };
+        }
+    }
+
+    return { path, isFileTemporary, writeToTemporaryFile };
+};

+ 85 - 99
desktop/src/preload.ts

@@ -37,37 +37,37 @@
  * -    [main]      desktop/src/main/ipc.ts                  contains impl
  * -    [main]      desktop/src/main/ipc.ts                  contains impl
  */
  */
 
 
-import { contextBridge, ipcRenderer } from "electron/renderer";
+import { contextBridge, ipcRenderer, webUtils } from "electron/renderer";
 
 
 // While we can't import other code, we can import types since they're just
 // While we can't import other code, we can import types since they're just
 // needed when compiling and will not be needed or looked around for at runtime.
 // needed when compiling and will not be needed or looked around for at runtime.
 import type {
 import type {
     AppUpdate,
     AppUpdate,
     CollectionMapping,
     CollectionMapping,
-    ElectronFile,
     FolderWatch,
     FolderWatch,
     PendingUploads,
     PendingUploads,
+    ZipItem,
 } from "./types/ipc";
 } from "./types/ipc";
 
 
 // - General
 // - General
 
 
-const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion");
+const appVersion = () => ipcRenderer.invoke("appVersion");
 
 
 const logToDisk = (message: string): void =>
 const logToDisk = (message: string): void =>
     ipcRenderer.send("logToDisk", message);
     ipcRenderer.send("logToDisk", message);
 
 
-const openDirectory = (dirPath: string): Promise<void> =>
+const openDirectory = (dirPath: string) =>
     ipcRenderer.invoke("openDirectory", dirPath);
     ipcRenderer.invoke("openDirectory", dirPath);
 
 
-const openLogDirectory = (): Promise<void> =>
-    ipcRenderer.invoke("openLogDirectory");
+const openLogDirectory = () => ipcRenderer.invoke("openLogDirectory");
+
+const selectDirectory = () => ipcRenderer.invoke("selectDirectory");
 
 
 const clearStores = () => ipcRenderer.send("clearStores");
 const clearStores = () => ipcRenderer.send("clearStores");
 
 
-const encryptionKey = (): Promise<string | undefined> =>
-    ipcRenderer.invoke("encryptionKey");
+const encryptionKey = () => ipcRenderer.invoke("encryptionKey");
 
 
-const saveEncryptionKey = (encryptionKey: string): Promise<void> =>
+const saveEncryptionKey = (encryptionKey: string) =>
     ipcRenderer.invoke("saveEncryptionKey", encryptionKey);
     ipcRenderer.invoke("saveEncryptionKey", encryptionKey);
 
 
 const onMainWindowFocus = (cb?: () => void) => {
 const onMainWindowFocus = (cb?: () => void) => {
@@ -99,121 +99,90 @@ const skipAppUpdate = (version: string) => {
 
 
 // - FS
 // - FS
 
 
-const fsExists = (path: string): Promise<boolean> =>
-    ipcRenderer.invoke("fsExists", path);
+const fsExists = (path: string) => ipcRenderer.invoke("fsExists", path);
 
 
-const fsMkdirIfNeeded = (dirPath: string): Promise<void> =>
+const fsMkdirIfNeeded = (dirPath: string) =>
     ipcRenderer.invoke("fsMkdirIfNeeded", dirPath);
     ipcRenderer.invoke("fsMkdirIfNeeded", dirPath);
 
 
-const fsRename = (oldPath: string, newPath: string): Promise<void> =>
+const fsRename = (oldPath: string, newPath: string) =>
     ipcRenderer.invoke("fsRename", oldPath, newPath);
     ipcRenderer.invoke("fsRename", oldPath, newPath);
 
 
-const fsRmdir = (path: string): Promise<void> =>
-    ipcRenderer.invoke("fsRmdir", path);
+const fsRmdir = (path: string) => ipcRenderer.invoke("fsRmdir", path);
 
 
-const fsRm = (path: string): Promise<void> => ipcRenderer.invoke("fsRm", path);
+const fsRm = (path: string) => ipcRenderer.invoke("fsRm", path);
 
 
-const fsReadTextFile = (path: string): Promise<string> =>
+const fsReadTextFile = (path: string) =>
     ipcRenderer.invoke("fsReadTextFile", path);
     ipcRenderer.invoke("fsReadTextFile", path);
 
 
-const fsWriteFile = (path: string, contents: string): Promise<void> =>
+const fsWriteFile = (path: string, contents: string) =>
     ipcRenderer.invoke("fsWriteFile", path, contents);
     ipcRenderer.invoke("fsWriteFile", path, contents);
 
 
-const fsIsDir = (dirPath: string): Promise<boolean> =>
-    ipcRenderer.invoke("fsIsDir", dirPath);
-
-const fsSize = (path: string): Promise<number> =>
-    ipcRenderer.invoke("fsSize", path);
+const fsIsDir = (dirPath: string) => ipcRenderer.invoke("fsIsDir", dirPath);
 
 
 // - Conversion
 // - Conversion
 
 
-const convertToJPEG = (imageData: Uint8Array): Promise<Uint8Array> =>
+const convertToJPEG = (imageData: Uint8Array) =>
     ipcRenderer.invoke("convertToJPEG", imageData);
     ipcRenderer.invoke("convertToJPEG", imageData);
 
 
 const generateImageThumbnail = (
 const generateImageThumbnail = (
-    dataOrPath: Uint8Array | string,
+    dataOrPathOrZipItem: Uint8Array | string | ZipItem,
     maxDimension: number,
     maxDimension: number,
     maxSize: number,
     maxSize: number,
-): Promise<Uint8Array> =>
+) =>
     ipcRenderer.invoke(
     ipcRenderer.invoke(
         "generateImageThumbnail",
         "generateImageThumbnail",
-        dataOrPath,
+        dataOrPathOrZipItem,
         maxDimension,
         maxDimension,
         maxSize,
         maxSize,
     );
     );
 
 
 const ffmpegExec = (
 const ffmpegExec = (
     command: string[],
     command: string[],
-    dataOrPath: Uint8Array | string,
+    dataOrPathOrZipItem: Uint8Array | string | ZipItem,
     outputFileExtension: string,
     outputFileExtension: string,
     timeoutMS: number,
     timeoutMS: number,
-): Promise<Uint8Array> =>
+) =>
     ipcRenderer.invoke(
     ipcRenderer.invoke(
         "ffmpegExec",
         "ffmpegExec",
         command,
         command,
-        dataOrPath,
+        dataOrPathOrZipItem,
         outputFileExtension,
         outputFileExtension,
         timeoutMS,
         timeoutMS,
     );
     );
 
 
 // - ML
 // - ML
 
 
-const clipImageEmbedding = (jpegImageData: Uint8Array): Promise<Float32Array> =>
+const clipImageEmbedding = (jpegImageData: Uint8Array) =>
     ipcRenderer.invoke("clipImageEmbedding", jpegImageData);
     ipcRenderer.invoke("clipImageEmbedding", jpegImageData);
 
 
-const clipTextEmbeddingIfAvailable = (
-    text: string,
-): Promise<Float32Array | undefined> =>
+const clipTextEmbeddingIfAvailable = (text: string) =>
     ipcRenderer.invoke("clipTextEmbeddingIfAvailable", text);
     ipcRenderer.invoke("clipTextEmbeddingIfAvailable", text);
 
 
-const detectFaces = (input: Float32Array): Promise<Float32Array> =>
+const detectFaces = (input: Float32Array) =>
     ipcRenderer.invoke("detectFaces", input);
     ipcRenderer.invoke("detectFaces", input);
 
 
-const faceEmbedding = (input: Float32Array): Promise<Float32Array> =>
+const faceEmbedding = (input: Float32Array) =>
     ipcRenderer.invoke("faceEmbedding", input);
     ipcRenderer.invoke("faceEmbedding", input);
 
 
-// - File selection
-
-// TODO: Deprecated - use dialogs on the renderer process itself
-
-const selectDirectory = (): Promise<string> =>
-    ipcRenderer.invoke("selectDirectory");
-
-const showUploadFilesDialog = (): Promise<ElectronFile[]> =>
-    ipcRenderer.invoke("showUploadFilesDialog");
-
-const showUploadDirsDialog = (): Promise<ElectronFile[]> =>
-    ipcRenderer.invoke("showUploadDirsDialog");
-
-const showUploadZipDialog = (): Promise<{
-    zipPaths: string[];
-    files: ElectronFile[];
-}> => ipcRenderer.invoke("showUploadZipDialog");
-
 // - Watch
 // - Watch
 
 
-const watchGet = (): Promise<FolderWatch[]> => ipcRenderer.invoke("watchGet");
+const watchGet = () => ipcRenderer.invoke("watchGet");
 
 
-const watchAdd = (
-    folderPath: string,
-    collectionMapping: CollectionMapping,
-): Promise<FolderWatch[]> =>
+const watchAdd = (folderPath: string, collectionMapping: CollectionMapping) =>
     ipcRenderer.invoke("watchAdd", folderPath, collectionMapping);
     ipcRenderer.invoke("watchAdd", folderPath, collectionMapping);
 
 
-const watchRemove = (folderPath: string): Promise<FolderWatch[]> =>
+const watchRemove = (folderPath: string) =>
     ipcRenderer.invoke("watchRemove", folderPath);
     ipcRenderer.invoke("watchRemove", folderPath);
 
 
 const watchUpdateSyncedFiles = (
 const watchUpdateSyncedFiles = (
     syncedFiles: FolderWatch["syncedFiles"],
     syncedFiles: FolderWatch["syncedFiles"],
     folderPath: string,
     folderPath: string,
-): Promise<void> =>
-    ipcRenderer.invoke("watchUpdateSyncedFiles", syncedFiles, folderPath);
+) => ipcRenderer.invoke("watchUpdateSyncedFiles", syncedFiles, folderPath);
 
 
 const watchUpdateIgnoredFiles = (
 const watchUpdateIgnoredFiles = (
     ignoredFiles: FolderWatch["ignoredFiles"],
     ignoredFiles: FolderWatch["ignoredFiles"],
     folderPath: string,
     folderPath: string,
-): Promise<void> =>
-    ipcRenderer.invoke("watchUpdateIgnoredFiles", ignoredFiles, folderPath);
+) => ipcRenderer.invoke("watchUpdateIgnoredFiles", ignoredFiles, folderPath);
 
 
 const watchOnAddFile = (f: (path: string, watch: FolderWatch) => void) => {
 const watchOnAddFile = (f: (path: string, watch: FolderWatch) => void) => {
     ipcRenderer.removeAllListeners("watchAddFile");
     ipcRenderer.removeAllListeners("watchAddFile");
@@ -236,33 +205,56 @@ const watchOnRemoveDir = (f: (path: string, watch: FolderWatch) => void) => {
     );
     );
 };
 };
 
 
-const watchFindFiles = (folderPath: string): Promise<string[]> =>
+const watchFindFiles = (folderPath: string) =>
     ipcRenderer.invoke("watchFindFiles", folderPath);
     ipcRenderer.invoke("watchFindFiles", folderPath);
 
 
+const watchReset = async () => {
+    ipcRenderer.removeAllListeners("watchAddFile");
+    ipcRenderer.removeAllListeners("watchRemoveFile");
+    ipcRenderer.removeAllListeners("watchRemoveDir");
+    await ipcRenderer.invoke("watchReset");
+};
+
 // - Upload
 // - Upload
 
 
-const pendingUploads = (): Promise<PendingUploads | undefined> =>
-    ipcRenderer.invoke("pendingUploads");
+const pathForFile = (file: File) => {
+    const path = webUtils.getPathForFile(file);
+    // The path that we get back from `webUtils.getPathForFile` on Windows uses
+    // "/" as the path separator. Convert them to POSIX separators.
+    //
+    // Note that we do not have access to the path or the os module in the
+    // preload script, thus this hand rolled transformation.
+
+    // However that makes TypeScript fidgety since we it cannot find navigator,
+    // as we haven't included "lib": ["dom"] in our tsconfig to avoid making DOM
+    // APIs available to our main Node.js code. We could create a separate
+    // tsconfig just for the preload script, but for now let's go with a cast.
+    //
+    // @ts-expect-error navigator is not defined.
+    const platform = (navigator as { platform: string }).platform;
+    return platform.toLowerCase().includes("win")
+        ? path.split("\\").join("/")
+        : path;
+};
+
+const listZipItems = (zipPath: string) =>
+    ipcRenderer.invoke("listZipItems", zipPath);
 
 
-const setPendingUploadCollection = (collectionName: string): Promise<void> =>
-    ipcRenderer.invoke("setPendingUploadCollection", collectionName);
+const pathOrZipItemSize = (pathOrZipItem: string | ZipItem) =>
+    ipcRenderer.invoke("pathOrZipItemSize", pathOrZipItem);
 
 
-const setPendingUploadFiles = (
-    type: PendingUploads["type"],
-    filePaths: string[],
-): Promise<void> =>
-    ipcRenderer.invoke("setPendingUploadFiles", type, filePaths);
+const pendingUploads = () => ipcRenderer.invoke("pendingUploads");
 
 
-// - TODO: AUDIT below this
-// -
+const setPendingUploads = (pendingUploads: PendingUploads) =>
+    ipcRenderer.invoke("setPendingUploads", pendingUploads);
 
 
-const getElectronFilesFromGoogleZip = (
-    filePath: string,
-): Promise<ElectronFile[]> =>
-    ipcRenderer.invoke("getElectronFilesFromGoogleZip", filePath);
+const markUploadedFiles = (paths: PendingUploads["filePaths"]) =>
+    ipcRenderer.invoke("markUploadedFiles", paths);
 
 
-const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
-    ipcRenderer.invoke("getDirFiles", dirPath);
+const markUploadedZipItems = (items: PendingUploads["zipItems"]) =>
+    ipcRenderer.invoke("markUploadedZipItems", items);
+
+const clearPendingUploads = () => ipcRenderer.invoke("clearPendingUploads");
 
 
 /**
 /**
  * These objects exposed here will become available to the JS code in our
  * These objects exposed here will become available to the JS code in our
@@ -311,6 +303,7 @@ contextBridge.exposeInMainWorld("electron", {
     logToDisk,
     logToDisk,
     openDirectory,
     openDirectory,
     openLogDirectory,
     openLogDirectory,
+    selectDirectory,
     clearStores,
     clearStores,
     encryptionKey,
     encryptionKey,
     saveEncryptionKey,
     saveEncryptionKey,
@@ -334,7 +327,6 @@ contextBridge.exposeInMainWorld("electron", {
         readTextFile: fsReadTextFile,
         readTextFile: fsReadTextFile,
         writeFile: fsWriteFile,
         writeFile: fsWriteFile,
         isDir: fsIsDir,
         isDir: fsIsDir,
-        size: fsSize,
     },
     },
 
 
     // - Conversion
     // - Conversion
@@ -350,35 +342,29 @@ contextBridge.exposeInMainWorld("electron", {
     detectFaces,
     detectFaces,
     faceEmbedding,
     faceEmbedding,
 
 
-    // - File selection
-
-    selectDirectory,
-    showUploadFilesDialog,
-    showUploadDirsDialog,
-    showUploadZipDialog,
-
     // - Watch
     // - Watch
 
 
     watch: {
     watch: {
         get: watchGet,
         get: watchGet,
         add: watchAdd,
         add: watchAdd,
         remove: watchRemove,
         remove: watchRemove,
+        updateSyncedFiles: watchUpdateSyncedFiles,
+        updateIgnoredFiles: watchUpdateIgnoredFiles,
         onAddFile: watchOnAddFile,
         onAddFile: watchOnAddFile,
         onRemoveFile: watchOnRemoveFile,
         onRemoveFile: watchOnRemoveFile,
         onRemoveDir: watchOnRemoveDir,
         onRemoveDir: watchOnRemoveDir,
         findFiles: watchFindFiles,
         findFiles: watchFindFiles,
-        updateSyncedFiles: watchUpdateSyncedFiles,
-        updateIgnoredFiles: watchUpdateIgnoredFiles,
+        reset: watchReset,
     },
     },
 
 
     // - Upload
     // - Upload
 
 
+    pathForFile,
+    listZipItems,
+    pathOrZipItemSize,
     pendingUploads,
     pendingUploads,
-    setPendingUploadCollection,
-    setPendingUploadFiles,
-
-    // -
-
-    getElectronFilesFromGoogleZip,
-    getDirFiles,
+    setPendingUploads,
+    markUploadedFiles,
+    markUploadedZipItems,
+    clearPendingUploads,
 });
 });

+ 5 - 0
desktop/src/thirdparty/clip-bpe-ts/mod.ts

@@ -1,3 +1,5 @@
+/* eslint-disable */
+
 import * as htmlEntities from "html-entities";
 import * as htmlEntities from "html-entities";
 import bpeVocabData from "./bpe_simple_vocab_16e6";
 import bpeVocabData from "./bpe_simple_vocab_16e6";
 // import ftfy from "https://deno.land/x/ftfy_pyodide@v0.1.1/mod.js";
 // import ftfy from "https://deno.land/x/ftfy_pyodide@v0.1.1/mod.js";
@@ -410,6 +412,7 @@ export default class {
                     newWord.push(first + second);
                     newWord.push(first + second);
                     i += 2;
                     i += 2;
                 } else {
                 } else {
+                    // @ts-expect-error "Array indexing can return undefined but not modifying thirdparty code"
                     newWord.push(word[i]);
                     newWord.push(word[i]);
                     i += 1;
                     i += 1;
                 }
                 }
@@ -434,6 +437,7 @@ export default class {
                 .map((b) => this.byteEncoder[b.charCodeAt(0) as number])
                 .map((b) => this.byteEncoder[b.charCodeAt(0) as number])
                 .join("");
                 .join("");
             bpeTokens.push(
             bpeTokens.push(
+                // @ts-expect-error "Array indexing can return undefined but not modifying thirdparty code"
                 ...this.bpe(token)
                 ...this.bpe(token)
                     .split(" ")
                     .split(" ")
                     .map((bpeToken: string) => this.encoder[bpeToken]),
                     .map((bpeToken: string) => this.encoder[bpeToken]),
@@ -458,6 +462,7 @@ export default class {
             .join("");
             .join("");
         text = [...text]
         text = [...text]
             .map((c) => this.byteDecoder[c])
             .map((c) => this.byteDecoder[c])
+            // @ts-expect-error "Array indexing can return undefined but not modifying thirdparty code"
             .map((v) => String.fromCharCode(v))
             .map((v) => String.fromCharCode(v))
             .join("")
             .join("")
             .replace(/<\/w>/g, " ");
             .replace(/<\/w>/g, " ");

+ 5 - 25
desktop/src/types/ipc.ts

@@ -25,10 +25,12 @@ export interface FolderWatchSyncedFile {
     collectionID: number;
     collectionID: number;
 }
 }
 
 
+export type ZipItem = [zipPath: string, entryName: string];
+
 export interface PendingUploads {
 export interface PendingUploads {
-    collectionName: string;
-    type: "files" | "zips";
-    files: ElectronFile[];
+    collectionName: string | undefined;
+    filePaths: string[];
+    zipItems: ZipItem[];
 }
 }
 
 
 /**
 /**
@@ -40,25 +42,3 @@ export interface PendingUploads {
 export const CustomErrorMessage = {
 export const CustomErrorMessage = {
     NotAvailable: "This feature in not available on the current OS/arch",
     NotAvailable: "This feature in not available on the current OS/arch",
 };
 };
-
-/**
- * Deprecated - Use File + webUtils.getPathForFile instead
- *
- * Electron used to augment the standard web
- * [File](https://developer.mozilla.org/en-US/docs/Web/API/File) object with an
- * additional `path` property. This is now deprecated, and will be removed in a
- * future release.
- * https://www.electronjs.org/docs/latest/api/file-object
- *
- * The alternative to the `path` property is to use `webUtils.getPathForFile`
- * https://www.electronjs.org/docs/latest/api/web-utils
- */
-export interface ElectronFile {
-    name: string;
-    path: string;
-    size: number;
-    lastModified: number;
-    stream: () => Promise<ReadableStream<Uint8Array>>;
-    blob: () => Promise<Blob>;
-    arrayBuffer: () => Promise<Uint8Array>;
-}

+ 19 - 56
desktop/tsconfig.json

@@ -3,71 +3,34 @@
        into JavaScript that'll then be loaded and run by the main (node) process
        into JavaScript that'll then be loaded and run by the main (node) process
        of our Electron app. */
        of our Electron app. */
 
 
-    /* TSConfig docs: https://aka.ms/tsconfig.json */
+    /*
+     * Recommended target, lib and other settings for code running in the
+     * version of Node.js bundled with Electron.
+     *
+     * Currently, with Electron 30, this is Node.js 20.11.1.
+     * https://www.electronjs.org/blog/electron-30-0
+     */
+    "extends": "@tsconfig/node20/tsconfig.json",
 
 
+    /* TSConfig docs: https://aka.ms/tsconfig.json */
     "compilerOptions": {
     "compilerOptions": {
-        /* Recommended target, lib and other settings for code running in the
-           version of Node.js bundled with Electron.
-
-           Currently, with Electron 29, this is Node.js 20.9
-           https://www.electronjs.org/blog/electron-29-0
-
-           Note that we cannot do
-
-               "extends": "@tsconfig/node20/tsconfig.json",
-
-           because that sets "lib": ["es2023"]. However (and I don't fully
-           understand what's going on here), that breaks our compilation since
-           tsc can then not find type definitions of things like ReadableStream.
-
-           Adding "dom" to "lib" (e.g. `"lib": ["es2023", "dom"]`) fixes the
-           issue, but that doesn't sound correct - the main Electron process
-           isn't running in a browser context.
-
-           It is possible that we're using some of the types incorrectly. For
-           now, we just omit the "lib" definition and rely on the defaults for
-           the "target" we've chosen. This is also what the current
-           electron-forge starter does:
-
-               yarn create electron-app electron-forge-starter -- --template=webpack-typescript
-
-           Enhancement: Can revisit this later.
-
-           Refs:
-           - https://github.com/electron/electron/issues/27092
-           - https://github.com/electron/electron/issues/16146
-        */
-
-        "target": "es2022",
-        "module": "node16",
-
-        /* Enable various workarounds to play better with CJS libraries */
-        "esModuleInterop": true,
-        /* Speed things up by not type checking `node_modules` */
-        "skipLibCheck": true,
-
         /* Emit the generated JS into `app/` */
         /* Emit the generated JS into `app/` */
         "outDir": "app",
         "outDir": "app",
 
 
-        /* Temporary overrides to get things to compile with the older config */
-        "strict": false,
-        "noImplicitAny": true
-
-        /* Below is the state we want */
-        /* Enable these one by one */
-        // "strict": true,
-
         /* Require the `type` modifier when importing types */
         /* Require the `type` modifier when importing types */
-        // "verbatimModuleSyntax": true
+        /* We want this, but it causes "ESM syntax is not allowed in a CommonJS
+           module when 'verbatimModuleSyntax' is enabled" currently */
+        /* "verbatimModuleSyntax": true, */
 
 
+        "strict": true,
         /* Stricter than strict */
         /* Stricter than strict */
-        // "noImplicitReturns": true,
-        // "noUnusedParameters": true,
-        // "noUnusedLocals": true,
-        // "noFallthroughCasesInSwitch": true,
+        "noImplicitReturns": true,
+        "noUnusedParameters": true,
+        "noUnusedLocals": true,
+        "noFallthroughCasesInSwitch": true,
         /* e.g. makes array indexing returns undefined */
         /* e.g. makes array indexing returns undefined */
-        // "noUncheckedIndexedAccess": true,
-        // "exactOptionalPropertyTypes": true,
+        "noUncheckedIndexedAccess": true,
+        "exactOptionalPropertyTypes": true
     },
     },
     /* Transpile all `.ts` files in `src/` */
     /* Transpile all `.ts` files in `src/` */
     "include": ["src/**/*.ts"]
     "include": ["src/**/*.ts"]

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 390 - 210
desktop/yarn.lock


+ 1 - 1
docs/docs/auth/migration-guides/authy/index.md

@@ -18,7 +18,7 @@ A guide written by Green, an ente.io lover
 Migrating from Authy can be tiring, as you cannot export your 2FA codes through
 Migrating from Authy can be tiring, as you cannot export your 2FA codes through
 the app, meaning that you would have to reconfigure 2FA for all of your accounts
 the app, meaning that you would have to reconfigure 2FA for all of your accounts
 for your new 2FA authenticator. However, easier ways exist to export your codes
 for your new 2FA authenticator. However, easier ways exist to export your codes
-out of Authy. This guide will cover two of the most used methods for mograting
+out of Authy. This guide will cover two of the most used methods for migrating
 from Authy to Ente Authenticator.
 from Authy to Ente Authenticator.
 
 
 > [!CAUTION]
 > [!CAUTION]

+ 5 - 2
docs/docs/self-hosting/guides/custom-server/index.md

@@ -25,10 +25,13 @@ configure the endpoint the app should be connecting to.
 > You can download the CLI from
 > You can download the CLI from
 > [here](https://github.com/ente-io/ente/releases?q=tag%3Acli-v0)
 > [here](https://github.com/ente-io/ente/releases?q=tag%3Acli-v0)
 
 
-Define a config.yaml and put it either in the same directory as CLI or path
-defined in env variable `ENTE_CLI_CONFIG_PATH`
+Define a config.yaml and put it either in the same directory as where you run
+the CLI from ("current working directory"), or in the path defined in env
+variable `ENTE_CLI_CONFIG_PATH`:
 
 
 ```yaml
 ```yaml
 endpoint:
 endpoint:
     api: "http://localhost:8080"
     api: "http://localhost:8080"
 ```
 ```
+
+(Another [example](https://github.com/ente-io/ente/blob/main/cli/config.yaml.example))

+ 0 - 6
mobile/ios/Podfile.lock

@@ -108,8 +108,6 @@ PODS:
     - FlutterMacOS
     - FlutterMacOS
   - integration_test (0.0.1):
   - integration_test (0.0.1):
     - Flutter
     - Flutter
-  - isar_flutter_libs (1.0.0):
-    - Flutter
   - libwebp (1.3.2):
   - libwebp (1.3.2):
     - libwebp/demux (= 1.3.2)
     - libwebp/demux (= 1.3.2)
     - libwebp/mux (= 1.3.2)
     - libwebp/mux (= 1.3.2)
@@ -246,7 +244,6 @@ DEPENDENCIES:
   - image_editor_common (from `.symlinks/plugins/image_editor_common/ios`)
   - image_editor_common (from `.symlinks/plugins/image_editor_common/ios`)
   - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
   - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
   - integration_test (from `.symlinks/plugins/integration_test/ios`)
   - integration_test (from `.symlinks/plugins/integration_test/ios`)
-  - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
   - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
   - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
   - local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
   - local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
   - media_extension (from `.symlinks/plugins/media_extension/ios`)
   - media_extension (from `.symlinks/plugins/media_extension/ios`)
@@ -341,8 +338,6 @@ EXTERNAL SOURCES:
     :path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
     :path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
   integration_test:
   integration_test:
     :path: ".symlinks/plugins/integration_test/ios"
     :path: ".symlinks/plugins/integration_test/ios"
-  isar_flutter_libs:
-    :path: ".symlinks/plugins/isar_flutter_libs/ios"
   local_auth_darwin:
   local_auth_darwin:
     :path: ".symlinks/plugins/local_auth_darwin/darwin"
     :path: ".symlinks/plugins/local_auth_darwin/darwin"
   local_auth_ios:
   local_auth_ios:
@@ -427,7 +422,6 @@ SPEC CHECKSUMS:
   image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
   image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
   in_app_purchase_storekit: 0e4b3c2e43ba1e1281f4f46dd71b0593ce529892
   in_app_purchase_storekit: 0e4b3c2e43ba1e1281f4f46dd71b0593ce529892
   integration_test: 13825b8a9334a850581300559b8839134b124670
   integration_test: 13825b8a9334a850581300559b8839134b124670
-  isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
   libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
   libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
   local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98
   local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98
   local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
   local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9

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

@@ -308,7 +308,6 @@
 				"${BUILT_PRODUCTS_DIR}/image_editor_common/image_editor_common.framework",
 				"${BUILT_PRODUCTS_DIR}/image_editor_common/image_editor_common.framework",
 				"${BUILT_PRODUCTS_DIR}/in_app_purchase_storekit/in_app_purchase_storekit.framework",
 				"${BUILT_PRODUCTS_DIR}/in_app_purchase_storekit/in_app_purchase_storekit.framework",
 				"${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework",
 				"${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework",
-				"${BUILT_PRODUCTS_DIR}/isar_flutter_libs/isar_flutter_libs.framework",
 				"${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework",
 				"${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework",
 				"${BUILT_PRODUCTS_DIR}/local_auth_darwin/local_auth_darwin.framework",
 				"${BUILT_PRODUCTS_DIR}/local_auth_darwin/local_auth_darwin.framework",
 				"${BUILT_PRODUCTS_DIR}/local_auth_ios/local_auth_ios.framework",
 				"${BUILT_PRODUCTS_DIR}/local_auth_ios/local_auth_ios.framework",
@@ -390,7 +389,6 @@
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_editor_common.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_editor_common.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/in_app_purchase_storekit.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/in_app_purchase_storekit.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework",
-				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/isar_flutter_libs.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_darwin.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_darwin.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_ios.framework",
 				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_ios.framework",

+ 9 - 0
mobile/ios/Runner/Info.plist

@@ -105,5 +105,14 @@
 		<true/>
 		<true/>
 		<key>UIApplicationSupportsIndirectInputEvents</key>
 		<key>UIApplicationSupportsIndirectInputEvents</key>
 		<true/>
 		<true/>
+		<key>NSBonjourServices</key>
+                <array>
+                  <string>_googlecast._tcp</string>
+                  <string>F5BCEC64._googlecast._tcp</string>
+                </array>
+
+                <key>NSLocalNetworkUsageDescription</key>
+                <string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi
+                network.</string>
 	</dict>
 	</dict>
 </plist>
 </plist>

+ 131 - 43
mobile/lib/db/embeddings_db.dart

@@ -1,79 +1,167 @@
 import "dart:io";
 import "dart:io";
+import "dart:typed_data";
 
 
-import "package:isar/isar.dart";
+import "package:path/path.dart";
 import 'package:path_provider/path_provider.dart';
 import 'package:path_provider/path_provider.dart';
 import "package:photos/core/event_bus.dart";
 import "package:photos/core/event_bus.dart";
 import "package:photos/events/embedding_updated_event.dart";
 import "package:photos/events/embedding_updated_event.dart";
 import "package:photos/models/embedding.dart";
 import "package:photos/models/embedding.dart";
+import "package:sqlite_async/sqlite_async.dart";
 
 
 class EmbeddingsDB {
 class EmbeddingsDB {
-  late final Isar _isar;
-
   EmbeddingsDB._privateConstructor();
   EmbeddingsDB._privateConstructor();
 
 
   static final EmbeddingsDB instance = EmbeddingsDB._privateConstructor();
   static final EmbeddingsDB instance = EmbeddingsDB._privateConstructor();
 
 
+  static const databaseName = "ente.embeddings.db";
+  static const tableName = "embeddings";
+  static const columnFileID = "file_id";
+  static const columnModel = "model";
+  static const columnEmbedding = "embedding";
+  static const columnUpdationTime = "updation_time";
+
+  static Future<SqliteDatabase>? _dbFuture;
+
+  Future<SqliteDatabase> get _database async {
+    _dbFuture ??= _initDatabase();
+    return _dbFuture!;
+  }
+
   Future<void> init() async {
   Future<void> init() async {
     final dir = await getApplicationDocumentsDirectory();
     final dir = await getApplicationDocumentsDirectory();
-    _isar = await Isar.open(
-      [EmbeddingSchema],
-      directory: dir.path,
-    );
-    await _clearDeprecatedStore(dir);
+    await _clearDeprecatedStores(dir);
+  }
+
+  Future<SqliteDatabase> _initDatabase() async {
+    final Directory documentsDirectory =
+        await getApplicationDocumentsDirectory();
+    final String path = join(documentsDirectory.path, databaseName);
+    final migrations = SqliteMigrations()
+      ..add(
+        SqliteMigration(
+          1,
+          (tx) async {
+            await tx.execute(
+              'CREATE TABLE $tableName ($columnFileID INTEGER NOT NULL, $columnModel INTEGER NOT NULL, $columnEmbedding BLOB NOT NULL, $columnUpdationTime INTEGER, UNIQUE ($columnFileID, $columnModel))',
+            );
+          },
+        ),
+      );
+    final database = SqliteDatabase(path: path);
+    await migrations.migrate(database);
+    return database;
   }
   }
 
 
   Future<void> clearTable() async {
   Future<void> clearTable() async {
-    await _isar.writeTxn(() => _isar.clear());
+    final db = await _database;
+    await db.execute('DELETE * FROM $tableName');
   }
   }
 
 
   Future<List<Embedding>> getAll(Model model) async {
   Future<List<Embedding>> getAll(Model model) async {
-    return _isar.embeddings.filter().modelEqualTo(model).findAll();
+    final db = await _database;
+    final results = await db.getAll('SELECT * FROM $tableName');
+    return _convertToEmbeddings(results);
   }
   }
 
 
-  Future<void> put(Embedding embedding) {
-    return _isar.writeTxn(() async {
-      await _isar.embeddings.putByIndex(Embedding.index, embedding);
-      Bus.instance.fire(EmbeddingUpdatedEvent());
-    });
+  Future<void> put(Embedding embedding) async {
+    final db = await _database;
+    await db.execute(
+      'INSERT OR REPLACE INTO $tableName ($columnFileID, $columnModel, $columnEmbedding, $columnUpdationTime) VALUES (?, ?, ?, ?)',
+      _getRowFromEmbedding(embedding),
+    );
+    Bus.instance.fire(EmbeddingUpdatedEvent());
   }
   }
 
 
-  Future<void> putMany(List<Embedding> embeddings) {
-    return _isar.writeTxn(() async {
-      await _isar.embeddings.putAllByIndex(Embedding.index, embeddings);
-      Bus.instance.fire(EmbeddingUpdatedEvent());
-    });
+  Future<void> putMany(List<Embedding> embeddings) async {
+    final db = await _database;
+    final inputs = embeddings.map((e) => _getRowFromEmbedding(e)).toList();
+    await db.executeBatch(
+      'INSERT OR REPLACE INTO $tableName ($columnFileID, $columnModel, $columnEmbedding, $columnUpdationTime) values(?, ?, ?, ?)',
+      inputs,
+    );
+    Bus.instance.fire(EmbeddingUpdatedEvent());
   }
   }
 
 
   Future<List<Embedding>> getUnsyncedEmbeddings() async {
   Future<List<Embedding>> getUnsyncedEmbeddings() async {
-    return await _isar.embeddings.filter().updationTimeEqualTo(null).findAll();
+    final db = await _database;
+    final results = await db.getAll(
+      'SELECT * FROM $tableName WHERE $columnUpdationTime IS NULL',
+    );
+    return _convertToEmbeddings(results);
   }
   }
 
 
   Future<void> deleteEmbeddings(List<int> fileIDs) async {
   Future<void> deleteEmbeddings(List<int> fileIDs) async {
-    await _isar.writeTxn(() async {
-      final embeddings = <Embedding>[];
-      for (final fileID in fileIDs) {
-        embeddings.addAll(
-          await _isar.embeddings.filter().fileIDEqualTo(fileID).findAll(),
-        );
-      }
-      await _isar.embeddings.deleteAll(embeddings.map((e) => e.id).toList());
-      Bus.instance.fire(EmbeddingUpdatedEvent());
-    });
+    final db = await _database;
+    await db.execute(
+      'DELETE FROM $tableName WHERE $columnFileID IN (${fileIDs.join(", ")})',
+    );
+    Bus.instance.fire(EmbeddingUpdatedEvent());
   }
   }
 
 
   Future<void> deleteAllForModel(Model model) async {
   Future<void> deleteAllForModel(Model model) async {
-    await _isar.writeTxn(() async {
-      final embeddings =
-          await _isar.embeddings.filter().modelEqualTo(model).findAll();
-      await _isar.embeddings.deleteAll(embeddings.map((e) => e.id).toList());
-      Bus.instance.fire(EmbeddingUpdatedEvent());
-    });
-  }
-
-  Future<void> _clearDeprecatedStore(Directory dir) async {
-    final deprecatedStore = Directory(dir.path + "/object-box-store");
-    if (await deprecatedStore.exists()) {
-      await deprecatedStore.delete(recursive: true);
+    final db = await _database;
+    await db.execute(
+      'DELETE FROM $tableName WHERE $columnModel = ?',
+      [modelToInt(model)!],
+    );
+    Bus.instance.fire(EmbeddingUpdatedEvent());
+  }
+
+  List<Embedding> _convertToEmbeddings(List<Map<String, dynamic>> results) {
+    final List<Embedding> embeddings = [];
+    for (final result in results) {
+      embeddings.add(_getEmbeddingFromRow(result));
+    }
+    return embeddings;
+  }
+
+  Embedding _getEmbeddingFromRow(Map<String, dynamic> row) {
+    final fileID = row[columnFileID];
+    final model = intToModel(row[columnModel])!;
+    final bytes = row[columnEmbedding] as Uint8List;
+    final list = Float32List.view(bytes.buffer);
+    return Embedding(fileID: fileID, model: model, embedding: list);
+  }
+
+  List<Object?> _getRowFromEmbedding(Embedding embedding) {
+    return [
+      embedding.fileID,
+      modelToInt(embedding.model)!,
+      Float32List.fromList(embedding.embedding).buffer.asUint8List(),
+      embedding.updationTime,
+    ];
+  }
+
+  Future<void> _clearDeprecatedStores(Directory dir) async {
+    final deprecatedObjectBox = Directory(dir.path + "/object-box-store");
+    if (await deprecatedObjectBox.exists()) {
+      await deprecatedObjectBox.delete(recursive: true);
+    }
+    final deprecatedIsar = File(dir.path + "/default.isar");
+    if (await deprecatedIsar.exists()) {
+      await deprecatedIsar.delete();
+    }
+  }
+
+  int? modelToInt(Model model) {
+    switch (model) {
+      case Model.onnxClip:
+        return 1;
+      case Model.ggmlClip:
+        return 2;
+      default:
+        return null;
+    }
+  }
+
+  Model? intToModel(int model) {
+    switch (model) {
+      case 1:
+        return Model.onnxClip;
+      case 2:
+        return Model.ggmlClip;
+      default:
+        return null;
     }
     }
   }
   }
 }
 }

+ 1 - 0
mobile/lib/db/files_db.dart

@@ -455,6 +455,7 @@ class FilesDB {
   }
   }
 
 
   Future<int> insert(EnteFile file) async {
   Future<int> insert(EnteFile file) async {
+    _logger.info("Inserting $file");
     final db = await instance.database;
     final db = await instance.database;
     return db.insert(
     return db.insert(
       filesTable,
       filesTable,

+ 12 - 4
mobile/lib/gateways/cast_gw.dart

@@ -12,10 +12,14 @@ class CastGateway {
       );
       );
       return response.data["publicKey"];
       return response.data["publicKey"];
     } catch (e) {
     } catch (e) {
-      if (e is DioError &&
-          e.response != null &&
-          e.response!.statusCode == 404) {
-        return null;
+      if (e is DioError && e.response != null) {
+        if (e.response!.statusCode == 404) {
+          return null;
+        } else if (e.response!.statusCode == 403) {
+          throw CastIPMismatchException();
+        } else {
+          rethrow;
+        }
       }
       }
       rethrow;
       rethrow;
     }
     }
@@ -48,3 +52,7 @@ class CastGateway {
     }
     }
   }
   }
 }
 }
+
+class CastIPMismatchException implements Exception {
+  CastIPMismatchException();
+}

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

@@ -357,6 +357,13 @@ class MessageLookup extends MessageLookupByLibrary {
                 "Authentication failed, please try again"),
                 "Authentication failed, please try again"),
         "authenticationSuccessful":
         "authenticationSuccessful":
             MessageLookupByLibrary.simpleMessage("Authentication successful!"),
             MessageLookupByLibrary.simpleMessage("Authentication successful!"),
+        "autoCastDialogBody": MessageLookupByLibrary.simpleMessage(
+            "You\'ll see available Cast devices here."),
+        "autoCastiOSPermission": MessageLookupByLibrary.simpleMessage(
+            "Make sure Local Network permissions are turned on for the Ente Photos app, in Settings."),
+        "autoPair": MessageLookupByLibrary.simpleMessage("Auto pair"),
+        "autoPairDesc": MessageLookupByLibrary.simpleMessage(
+            "Auto pair works only with devices that support Chromecast."),
         "available": MessageLookupByLibrary.simpleMessage("Available"),
         "available": MessageLookupByLibrary.simpleMessage("Available"),
         "backedUpFolders":
         "backedUpFolders":
             MessageLookupByLibrary.simpleMessage("Backed up folders"),
             MessageLookupByLibrary.simpleMessage("Backed up folders"),
@@ -387,6 +394,10 @@ class MessageLookup extends MessageLookupByLibrary {
         "cannotAddMorePhotosAfterBecomingViewer": m9,
         "cannotAddMorePhotosAfterBecomingViewer": m9,
         "cannotDeleteSharedFiles":
         "cannotDeleteSharedFiles":
             MessageLookupByLibrary.simpleMessage("Cannot delete shared files"),
             MessageLookupByLibrary.simpleMessage("Cannot delete shared files"),
+        "castIPMismatchBody": MessageLookupByLibrary.simpleMessage(
+            "Please make sure you are on the same network as the TV."),
+        "castIPMismatchTitle":
+            MessageLookupByLibrary.simpleMessage("Failed to cast album"),
         "castInstruction": MessageLookupByLibrary.simpleMessage(
         "castInstruction": MessageLookupByLibrary.simpleMessage(
             "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV."),
             "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV."),
         "centerPoint": MessageLookupByLibrary.simpleMessage("Center point"),
         "centerPoint": MessageLookupByLibrary.simpleMessage("Center point"),
@@ -460,6 +471,8 @@ class MessageLookup extends MessageLookupByLibrary {
             MessageLookupByLibrary.simpleMessage("Confirm recovery key"),
             MessageLookupByLibrary.simpleMessage("Confirm recovery key"),
         "confirmYourRecoveryKey":
         "confirmYourRecoveryKey":
             MessageLookupByLibrary.simpleMessage("Confirm your recovery key"),
             MessageLookupByLibrary.simpleMessage("Confirm your recovery key"),
+        "connectToDevice":
+            MessageLookupByLibrary.simpleMessage("Connect to device"),
         "contactFamilyAdmin": m12,
         "contactFamilyAdmin": m12,
         "contactSupport":
         "contactSupport":
             MessageLookupByLibrary.simpleMessage("Contact support"),
             MessageLookupByLibrary.simpleMessage("Contact support"),
@@ -721,6 +734,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "filesBackedUpFromDevice": m22,
         "filesBackedUpFromDevice": m22,
         "filesBackedUpInAlbum": m23,
         "filesBackedUpInAlbum": m23,
         "filesDeleted": MessageLookupByLibrary.simpleMessage("Files deleted"),
         "filesDeleted": MessageLookupByLibrary.simpleMessage("Files deleted"),
+        "filesSavedToGallery":
+            MessageLookupByLibrary.simpleMessage("Files saved to gallery"),
         "flip": MessageLookupByLibrary.simpleMessage("Flip"),
         "flip": MessageLookupByLibrary.simpleMessage("Flip"),
         "forYourMemories":
         "forYourMemories":
             MessageLookupByLibrary.simpleMessage("for your memories"),
             MessageLookupByLibrary.simpleMessage("for your memories"),
@@ -902,6 +917,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "manageParticipants": MessageLookupByLibrary.simpleMessage("Manage"),
         "manageParticipants": MessageLookupByLibrary.simpleMessage("Manage"),
         "manageSubscription":
         "manageSubscription":
             MessageLookupByLibrary.simpleMessage("Manage subscription"),
             MessageLookupByLibrary.simpleMessage("Manage subscription"),
+        "manualPairDesc": MessageLookupByLibrary.simpleMessage(
+            "Pair with PIN works with any screen you wish to view your album on."),
         "map": MessageLookupByLibrary.simpleMessage("Map"),
         "map": MessageLookupByLibrary.simpleMessage("Map"),
         "maps": MessageLookupByLibrary.simpleMessage("Maps"),
         "maps": MessageLookupByLibrary.simpleMessage("Maps"),
         "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"),
         "mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"),
@@ -936,6 +953,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "no": MessageLookupByLibrary.simpleMessage("No"),
         "no": MessageLookupByLibrary.simpleMessage("No"),
         "noAlbumsSharedByYouYet":
         "noAlbumsSharedByYouYet":
             MessageLookupByLibrary.simpleMessage("No albums shared by you yet"),
             MessageLookupByLibrary.simpleMessage("No albums shared by you yet"),
+        "noDeviceFound":
+            MessageLookupByLibrary.simpleMessage("No device found"),
         "noDeviceLimit": MessageLookupByLibrary.simpleMessage("None"),
         "noDeviceLimit": MessageLookupByLibrary.simpleMessage("None"),
         "noDeviceThatCanBeDeleted": MessageLookupByLibrary.simpleMessage(
         "noDeviceThatCanBeDeleted": MessageLookupByLibrary.simpleMessage(
             "You\'ve no files on this device that can be deleted"),
             "You\'ve no files on this device that can be deleted"),
@@ -982,6 +1001,9 @@ class MessageLookup extends MessageLookupByLibrary {
         "orPickAnExistingOne":
         "orPickAnExistingOne":
             MessageLookupByLibrary.simpleMessage("Or pick an existing one"),
             MessageLookupByLibrary.simpleMessage("Or pick an existing one"),
         "pair": MessageLookupByLibrary.simpleMessage("Pair"),
         "pair": MessageLookupByLibrary.simpleMessage("Pair"),
+        "pairWithPin": MessageLookupByLibrary.simpleMessage("Pair with PIN"),
+        "pairingComplete":
+            MessageLookupByLibrary.simpleMessage("Pairing complete"),
         "passkey": MessageLookupByLibrary.simpleMessage("Passkey"),
         "passkey": MessageLookupByLibrary.simpleMessage("Passkey"),
         "passkeyAuthTitle":
         "passkeyAuthTitle":
             MessageLookupByLibrary.simpleMessage("Passkey verification"),
             MessageLookupByLibrary.simpleMessage("Passkey verification"),
@@ -1328,6 +1350,10 @@ class MessageLookup extends MessageLookupByLibrary {
         "sparkleSuccess": MessageLookupByLibrary.simpleMessage("✨ Success"),
         "sparkleSuccess": MessageLookupByLibrary.simpleMessage("✨ Success"),
         "startBackup": MessageLookupByLibrary.simpleMessage("Start backup"),
         "startBackup": MessageLookupByLibrary.simpleMessage("Start backup"),
         "status": MessageLookupByLibrary.simpleMessage("Status"),
         "status": MessageLookupByLibrary.simpleMessage("Status"),
+        "stopCastingBody": MessageLookupByLibrary.simpleMessage(
+            "Do you want to stop casting?"),
+        "stopCastingTitle":
+            MessageLookupByLibrary.simpleMessage("Stop casting"),
         "storage": MessageLookupByLibrary.simpleMessage("Storage"),
         "storage": MessageLookupByLibrary.simpleMessage("Storage"),
         "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Family"),
         "storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Family"),
         "storageBreakupYou": MessageLookupByLibrary.simpleMessage("You"),
         "storageBreakupYou": MessageLookupByLibrary.simpleMessage("You"),

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

@@ -5945,6 +5945,16 @@ class S {
     );
     );
   }
   }
 
 
+  /// `Files saved to gallery`
+  String get filesSavedToGallery {
+    return Intl.message(
+      'Files saved to gallery',
+      name: 'filesSavedToGallery',
+      desc: '',
+      args: [],
+    );
+  }
+
   /// `Failed to save file to gallery`
   /// `Failed to save file to gallery`
   String get fileFailedToSaveToGallery {
   String get fileFailedToSaveToGallery {
     return Intl.message(
     return Intl.message(
@@ -8378,6 +8388,26 @@ class S {
     );
     );
   }
   }
 
 
+  /// `Auto pair`
+  String get autoPair {
+    return Intl.message(
+      'Auto pair',
+      name: 'autoPair',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pair with PIN`
+  String get pairWithPin {
+    return Intl.message(
+      'Pair with PIN',
+      name: 'pairWithPin',
+      desc: '',
+      args: [],
+    );
+  }
+
   /// `Device not found`
   /// `Device not found`
   String get deviceNotFound {
   String get deviceNotFound {
     return Intl.message(
     return Intl.message(
@@ -8563,6 +8593,116 @@ class S {
       args: [],
       args: [],
     );
     );
   }
   }
+
+  /// `Auto pair works only with devices that support Chromecast.`
+  String get autoPairDesc {
+    return Intl.message(
+      'Auto pair works only with devices that support Chromecast.',
+      name: 'autoPairDesc',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pair with PIN works with any screen you wish to view your album on.`
+  String get manualPairDesc {
+    return Intl.message(
+      'Pair with PIN works with any screen you wish to view your album on.',
+      name: 'manualPairDesc',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Connect to device`
+  String get connectToDevice {
+    return Intl.message(
+      'Connect to device',
+      name: 'connectToDevice',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `You'll see available Cast devices here.`
+  String get autoCastDialogBody {
+    return Intl.message(
+      'You\'ll see available Cast devices here.',
+      name: 'autoCastDialogBody',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.`
+  String get autoCastiOSPermission {
+    return Intl.message(
+      'Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.',
+      name: 'autoCastiOSPermission',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `No device found`
+  String get noDeviceFound {
+    return Intl.message(
+      'No device found',
+      name: 'noDeviceFound',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Stop casting`
+  String get stopCastingTitle {
+    return Intl.message(
+      'Stop casting',
+      name: 'stopCastingTitle',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Do you want to stop casting?`
+  String get stopCastingBody {
+    return Intl.message(
+      'Do you want to stop casting?',
+      name: 'stopCastingBody',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Failed to cast album`
+  String get castIPMismatchTitle {
+    return Intl.message(
+      'Failed to cast album',
+      name: 'castIPMismatchTitle',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Please make sure you are on the same network as the TV.`
+  String get castIPMismatchBody {
+    return Intl.message(
+      'Please make sure you are on the same network as the TV.',
+      name: 'castIPMismatchBody',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pairing complete`
+  String get pairingComplete {
+    return Intl.message(
+      'Pairing complete',
+      name: 'pairingComplete',
+      desc: '',
+      args: [],
+    );
+  }
 }
 }
 
 
 class AppLocalizationDelegate extends LocalizationsDelegate<S> {
 class AppLocalizationDelegate extends LocalizationsDelegate<S> {

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

@@ -835,6 +835,7 @@
   "close": "Close",
   "close": "Close",
   "setAs": "Set as",
   "setAs": "Set as",
   "fileSavedToGallery": "File saved to gallery",
   "fileSavedToGallery": "File saved to gallery",
+  "filesSavedToGallery": "Files saved to gallery",
   "fileFailedToSaveToGallery": "Failed to save file to gallery",
   "fileFailedToSaveToGallery": "Failed to save file to gallery",
   "download": "Download",
   "download": "Download",
   "pressAndHoldToPlayVideo": "Press and hold to play video",
   "pressAndHoldToPlayVideo": "Press and hold to play video",
@@ -1195,6 +1196,8 @@
   "verifyPasskey": "Verify passkey",
   "verifyPasskey": "Verify passkey",
   "playOnTv": "Play album on TV",
   "playOnTv": "Play album on TV",
   "pair": "Pair",
   "pair": "Pair",
+  "autoPair": "Auto pair",
+  "pairWithPin": "Pair with PIN",
   "deviceNotFound": "Device not found",
   "deviceNotFound": "Device not found",
   "castInstruction": "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.",
   "castInstruction": "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.",
   "deviceCodeHint": "Enter the code",
   "deviceCodeHint": "Enter the code",
@@ -1212,5 +1215,16 @@
   "endpointUpdatedMessage": "Endpoint updated successfully",
   "endpointUpdatedMessage": "Endpoint updated successfully",
   "customEndpoint": "Connected to {endpoint}",
   "customEndpoint": "Connected to {endpoint}",
   "createCollaborativeLink": "Create collaborative link",
   "createCollaborativeLink": "Create collaborative link",
-  "search": "Search"
+  "search": "Search",
+  "autoPairDesc": "Auto pair works only with devices that support Chromecast.",
+  "manualPairDesc": "Pair with PIN works with any screen you wish to view your album on.",
+  "connectToDevice": "Connect to device",
+  "autoCastDialogBody": "You'll see available Cast devices here.",
+  "autoCastiOSPermission": "Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.",
+  "noDeviceFound": "No device found",
+  "stopCastingTitle": "Stop casting",
+  "stopCastingBody": "Do you want to stop casting?",
+  "castIPMismatchTitle": "Failed to cast album",
+  "castIPMismatchBody": "Please make sure you are on the same network as the TV.",
+  "pairingComplete": "Pairing complete"
 }
 }

+ 22 - 8
mobile/lib/l10n/intl_pt.arb

@@ -47,7 +47,7 @@
   "noRecoveryKey": "Nenhuma chave de recuperação?",
   "noRecoveryKey": "Nenhuma chave de recuperação?",
   "sorry": "Desculpe",
   "sorry": "Desculpe",
   "noRecoveryKeyNoDecryption": "Devido à natureza do nosso protocolo de criptografia de ponta a ponta, seus dados não podem ser descriptografados sem sua senha ou chave de recuperação",
   "noRecoveryKeyNoDecryption": "Devido à natureza do nosso protocolo de criptografia de ponta a ponta, seus dados não podem ser descriptografados sem sua senha ou chave de recuperação",
-  "verifyEmail": "Verificar email",
+  "verifyEmail": "Verificar e-mail",
   "toResetVerifyEmail": "Para redefinir a sua senha, por favor verifique o seu email primeiro.",
   "toResetVerifyEmail": "Para redefinir a sua senha, por favor verifique o seu email primeiro.",
   "checkInboxAndSpamFolder": "Verifique sua caixa de entrada (e ‘spam’) para concluir a verificação",
   "checkInboxAndSpamFolder": "Verifique sua caixa de entrada (e ‘spam’) para concluir a verificação",
   "tapToEnterCode": "Toque para inserir código",
   "tapToEnterCode": "Toque para inserir código",
@@ -156,7 +156,7 @@
   "addANewEmail": "Adicionar um novo email",
   "addANewEmail": "Adicionar um novo email",
   "orPickAnExistingOne": "Ou escolha um existente",
   "orPickAnExistingOne": "Ou escolha um existente",
   "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Os colaboradores podem adicionar fotos e vídeos ao álbum compartilhado.",
   "collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Os colaboradores podem adicionar fotos e vídeos ao álbum compartilhado.",
-  "enterEmail": "Digite o email",
+  "enterEmail": "Insira o e-mail",
   "albumOwner": "Proprietário",
   "albumOwner": "Proprietário",
   "@albumOwner": {
   "@albumOwner": {
     "description": "Role of the album owner"
     "description": "Role of the album owner"
@@ -186,7 +186,7 @@
   "passwordLock": "Bloqueio de senha",
   "passwordLock": "Bloqueio de senha",
   "disableDownloadWarningTitle": "Observe",
   "disableDownloadWarningTitle": "Observe",
   "disableDownloadWarningBody": "Os espectadores ainda podem tirar screenshots ou salvar uma cópia de suas fotos usando ferramentas externas",
   "disableDownloadWarningBody": "Os espectadores ainda podem tirar screenshots ou salvar uma cópia de suas fotos usando ferramentas externas",
-  "allowDownloads": "Permitir transferências",
+  "allowDownloads": "Permitir downloads",
   "linkDeviceLimit": "Limite do dispositivo",
   "linkDeviceLimit": "Limite do dispositivo",
   "noDeviceLimit": "Nenhum",
   "noDeviceLimit": "Nenhum",
   "@noDeviceLimit": {
   "@noDeviceLimit": {
@@ -334,12 +334,12 @@
   "removeParticipantBody": "{userEmail} será removido deste álbum compartilhado\n\nQuaisquer fotos adicionadas por eles também serão removidas do álbum",
   "removeParticipantBody": "{userEmail} será removido deste álbum compartilhado\n\nQuaisquer fotos adicionadas por eles também serão removidas do álbum",
   "keepPhotos": "Manter fotos",
   "keepPhotos": "Manter fotos",
   "deletePhotos": "Excluir fotos",
   "deletePhotos": "Excluir fotos",
-  "inviteToEnte": "Convidar para o ente",
+  "inviteToEnte": "Convidar para o Ente",
   "removePublicLink": "Remover link público",
   "removePublicLink": "Remover link público",
   "disableLinkMessage": "Isso removerá o link público para acessar \"{albumName}\".",
   "disableLinkMessage": "Isso removerá o link público para acessar \"{albumName}\".",
   "sharing": "Compartilhando...",
   "sharing": "Compartilhando...",
   "youCannotShareWithYourself": "Você não pode compartilhar consigo mesmo",
   "youCannotShareWithYourself": "Você não pode compartilhar consigo mesmo",
-  "archive": "Arquivado",
+  "archive": "Arquivar",
   "createAlbumActionHint": "Pressione e segure para selecionar fotos e clique em + para criar um álbum",
   "createAlbumActionHint": "Pressione e segure para selecionar fotos e clique em + para criar um álbum",
   "importing": "Importando....",
   "importing": "Importando....",
   "failedToLoadAlbums": "Falha ao carregar álbuns",
   "failedToLoadAlbums": "Falha ao carregar álbuns",
@@ -353,7 +353,7 @@
   "singleFileInBothLocalAndRemote": "Este {fileType} está tanto no Ente quanto no seu dispositivo.",
   "singleFileInBothLocalAndRemote": "Este {fileType} está tanto no Ente quanto no seu dispositivo.",
   "singleFileInRemoteOnly": "Este {fileType} será excluído do Ente.",
   "singleFileInRemoteOnly": "Este {fileType} será excluído do Ente.",
   "singleFileDeleteFromDevice": "Este {fileType} será excluído do seu dispositivo.",
   "singleFileDeleteFromDevice": "Este {fileType} será excluído do seu dispositivo.",
-  "deleteFromEnte": "Excluir do ente",
+  "deleteFromEnte": "Excluir do Ente",
   "yesDelete": "Sim, excluir",
   "yesDelete": "Sim, excluir",
   "movedToTrash": "Movido para a lixeira",
   "movedToTrash": "Movido para a lixeira",
   "deleteFromDevice": "Excluir do dispositivo",
   "deleteFromDevice": "Excluir do dispositivo",
@@ -473,7 +473,7 @@
   "ignoreUpdate": "Ignorar",
   "ignoreUpdate": "Ignorar",
   "downloading": "Baixando...",
   "downloading": "Baixando...",
   "cannotDeleteSharedFiles": "Não é possível excluir arquivos compartilhados",
   "cannotDeleteSharedFiles": "Não é possível excluir arquivos compartilhados",
-  "theDownloadCouldNotBeCompleted": "Não foi possível concluir a transferência",
+  "theDownloadCouldNotBeCompleted": "Não foi possível concluir o download",
   "retry": "Tentar novamente",
   "retry": "Tentar novamente",
   "backedUpFolders": "Backup de pastas concluído",
   "backedUpFolders": "Backup de pastas concluído",
   "backup": "Backup",
   "backup": "Backup",
@@ -835,6 +835,7 @@
   "close": "Fechar",
   "close": "Fechar",
   "setAs": "Definir como",
   "setAs": "Definir como",
   "fileSavedToGallery": "Vídeo salvo na galeria",
   "fileSavedToGallery": "Vídeo salvo na galeria",
+  "filesSavedToGallery": "Arquivos salvos na galeria",
   "fileFailedToSaveToGallery": "Falha ao salvar o arquivo na galeria",
   "fileFailedToSaveToGallery": "Falha ao salvar o arquivo na galeria",
   "download": "Baixar",
   "download": "Baixar",
   "pressAndHoldToPlayVideo": "Pressione e segure para reproduzir o vídeo",
   "pressAndHoldToPlayVideo": "Pressione e segure para reproduzir o vídeo",
@@ -1195,6 +1196,8 @@
   "verifyPasskey": "Verificar chave de acesso",
   "verifyPasskey": "Verificar chave de acesso",
   "playOnTv": "Reproduzir álbum na TV",
   "playOnTv": "Reproduzir álbum na TV",
   "pair": "Parear",
   "pair": "Parear",
+  "autoPair": "Pareamento automático",
+  "pairWithPin": "Parear com PIN",
   "deviceNotFound": "Dispositivo não encontrado",
   "deviceNotFound": "Dispositivo não encontrado",
   "castInstruction": "Visite cast.ente.io no dispositivo que você deseja parear.\n\ndigite o código abaixo para reproduzir o álbum em sua TV.",
   "castInstruction": "Visite cast.ente.io no dispositivo que você deseja parear.\n\ndigite o código abaixo para reproduzir o álbum em sua TV.",
   "deviceCodeHint": "Insira o código",
   "deviceCodeHint": "Insira o código",
@@ -1212,5 +1215,16 @@
   "endpointUpdatedMessage": "Endpoint atualizado com sucesso",
   "endpointUpdatedMessage": "Endpoint atualizado com sucesso",
   "customEndpoint": "Conectado a {endpoint}",
   "customEndpoint": "Conectado a {endpoint}",
   "createCollaborativeLink": "Criar link colaborativo",
   "createCollaborativeLink": "Criar link colaborativo",
-  "search": "Pesquisar"
+  "search": "Pesquisar",
+  "autoPairGoogle": "O Pareamento Automático requer a conexão com servidores do Google e só funciona com dispositivos Chromecast. O Google não receberá dados confidenciais, como suas fotos.",
+  "manualPairDesc": "Parear com o PIN funciona para qualquer dispositivo de tela grande onde você deseja reproduzir seu álbum.",
+  "connectToDevice": "Conectar ao dispositivo",
+  "autoCastDialogBody": "Você verá dispositivos disponíveis para transmitir aqui.",
+  "autoCastiOSPermission": "Certifique-se de que as permissões de Rede local estão ativadas para o aplicativo de Fotos Ente, em Configurações.",
+  "noDeviceFound": "Nenhum dispositivo encontrado",
+  "stopCastingTitle": "Parar transmissão",
+  "stopCastingBody": "Você quer parar a transmissão?",
+  "castIPMismatchTitle": "Falha ao transmitir álbum",
+  "castIPMismatchBody": "Certifique-se de estar na mesma rede que a TV.",
+  "pairingComplete": "Pareamento concluído"
 }
 }

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

@@ -835,6 +835,7 @@
   "close": "关闭",
   "close": "关闭",
   "setAs": "设置为",
   "setAs": "设置为",
   "fileSavedToGallery": "文件已保存到相册",
   "fileSavedToGallery": "文件已保存到相册",
+  "filesSavedToGallery": "多个文件已保存到相册",
   "fileFailedToSaveToGallery": "无法将文件保存到相册",
   "fileFailedToSaveToGallery": "无法将文件保存到相册",
   "download": "下载",
   "download": "下载",
   "pressAndHoldToPlayVideo": "按住以播放视频",
   "pressAndHoldToPlayVideo": "按住以播放视频",
@@ -1195,6 +1196,8 @@
   "verifyPasskey": "验证通行密钥",
   "verifyPasskey": "验证通行密钥",
   "playOnTv": "在电视上播放相册",
   "playOnTv": "在电视上播放相册",
   "pair": "配对",
   "pair": "配对",
+  "autoPair": "自动配对",
+  "pairWithPin": "用 PIN 配对",
   "deviceNotFound": "未发现设备",
   "deviceNotFound": "未发现设备",
   "castInstruction": "在您要配对的设备上访问 cast.ente.io。\n输入下面的代码即可在电视上播放相册。",
   "castInstruction": "在您要配对的设备上访问 cast.ente.io。\n输入下面的代码即可在电视上播放相册。",
   "deviceCodeHint": "输入代码",
   "deviceCodeHint": "输入代码",
@@ -1212,5 +1215,16 @@
   "endpointUpdatedMessage": "端点更新成功",
   "endpointUpdatedMessage": "端点更新成功",
   "customEndpoint": "已连接至 {endpoint}",
   "customEndpoint": "已连接至 {endpoint}",
   "createCollaborativeLink": "创建协作链接",
   "createCollaborativeLink": "创建协作链接",
-  "search": "搜索"
+  "search": "搜索",
+  "autoPairGoogle": "自动配对需要连接到 Google 服务器,且仅适用于支持 Chromecast 的设备。Google 不会接收敏感数据,例如您的照片。",
+  "manualPairDesc": "用 PIN 配对适用于任何大屏幕设备,您可以在这些设备上播放您的相册。",
+  "connectToDevice": "连接到设备",
+  "autoCastDialogBody": "您将在此处看到可用的 Cast 设备。",
+  "autoCastiOSPermission": "请确保已在“设置”中为 Ente Photos 应用打开本地网络权限。",
+  "noDeviceFound": "未发现设备",
+  "stopCastingTitle": "停止投放",
+  "stopCastingBody": "您想停止投放吗?",
+  "castIPMismatchTitle": "投放相册失败",
+  "castIPMismatchBody": "请确保您的设备与电视处于同一网络。",
+  "pairingComplete": "配对完成"
 }
 }

+ 6 - 0
mobile/lib/main.dart

@@ -1,5 +1,6 @@
 import 'dart:async';
 import 'dart:async';
 import 'dart:io';
 import 'dart:io';
+import "dart:isolate";
 
 
 import "package:adaptive_theme/adaptive_theme.dart";
 import "package:adaptive_theme/adaptive_theme.dart";
 import 'package:background_fetch/background_fetch.dart';
 import 'package:background_fetch/background_fetch.dart';
@@ -330,10 +331,15 @@ Future<void> _killBGTask([String? taskId]) async {
     DateTime.now().microsecondsSinceEpoch,
     DateTime.now().microsecondsSinceEpoch,
   );
   );
   final prefs = await SharedPreferences.getInstance();
   final prefs = await SharedPreferences.getInstance();
+
   await prefs.remove(kLastBGTaskHeartBeatTime);
   await prefs.remove(kLastBGTaskHeartBeatTime);
   if (taskId != null) {
   if (taskId != null) {
     BackgroundFetch.finish(taskId);
     BackgroundFetch.finish(taskId);
   }
   }
+
+  ///Band aid for background process not getting killed. Should migrate to using
+  ///workmanager instead of background_fetch.
+  Isolate.current.kill();
 }
 }
 
 
 Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
 Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {

+ 0 - 10
mobile/lib/models/embedding.dart

@@ -1,17 +1,7 @@
 import "dart:convert";
 import "dart:convert";
 
 
-import "package:isar/isar.dart";
-
-part 'embedding.g.dart';
-
-@collection
 class Embedding {
 class Embedding {
-  static const index = 'unique_file_model_embedding';
-
-  Id id = Isar.autoIncrement;
   final int fileID;
   final int fileID;
-  @enumerated
-  @Index(name: index, composite: [CompositeIndex('fileID')], unique: true, replace: true)
   final Model model;
   final Model model;
   final List<double> embedding;
   final List<double> embedding;
   int? updationTime;
   int? updationTime;

+ 0 - 1059
mobile/lib/models/embedding.g.dart

@@ -1,1059 +0,0 @@
-// GENERATED CODE - DO NOT MODIFY BY HAND
-
-part of 'embedding.dart';
-
-// **************************************************************************
-// IsarCollectionGenerator
-// **************************************************************************
-
-// coverage:ignore-file
-// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
-
-extension GetEmbeddingCollection on Isar {
-  IsarCollection<Embedding> get embeddings => this.collection();
-}
-
-const EmbeddingSchema = CollectionSchema(
-  name: r'Embedding',
-  id: -8064100183150254587,
-  properties: {
-    r'embedding': PropertySchema(
-      id: 0,
-      name: r'embedding',
-      type: IsarType.doubleList,
-    ),
-    r'fileID': PropertySchema(
-      id: 1,
-      name: r'fileID',
-      type: IsarType.long,
-    ),
-    r'model': PropertySchema(
-      id: 2,
-      name: r'model',
-      type: IsarType.byte,
-      enumMap: _EmbeddingmodelEnumValueMap,
-    ),
-    r'updationTime': PropertySchema(
-      id: 3,
-      name: r'updationTime',
-      type: IsarType.long,
-    )
-  },
-  estimateSize: _embeddingEstimateSize,
-  serialize: _embeddingSerialize,
-  deserialize: _embeddingDeserialize,
-  deserializeProp: _embeddingDeserializeProp,
-  idName: r'id',
-  indexes: {
-    r'unique_file_model_embedding': IndexSchema(
-      id: 6248303800853228628,
-      name: r'unique_file_model_embedding',
-      unique: true,
-      replace: true,
-      properties: [
-        IndexPropertySchema(
-          name: r'model',
-          type: IndexType.value,
-          caseSensitive: false,
-        ),
-        IndexPropertySchema(
-          name: r'fileID',
-          type: IndexType.value,
-          caseSensitive: false,
-        )
-      ],
-    )
-  },
-  links: {},
-  embeddedSchemas: {},
-  getId: _embeddingGetId,
-  getLinks: _embeddingGetLinks,
-  attach: _embeddingAttach,
-  version: '3.1.0+1',
-);
-
-int _embeddingEstimateSize(
-  Embedding object,
-  List<int> offsets,
-  Map<Type, List<int>> allOffsets,
-) {
-  var bytesCount = offsets.last;
-  bytesCount += 3 + object.embedding.length * 8;
-  return bytesCount;
-}
-
-void _embeddingSerialize(
-  Embedding object,
-  IsarWriter writer,
-  List<int> offsets,
-  Map<Type, List<int>> allOffsets,
-) {
-  writer.writeDoubleList(offsets[0], object.embedding);
-  writer.writeLong(offsets[1], object.fileID);
-  writer.writeByte(offsets[2], object.model.index);
-  writer.writeLong(offsets[3], object.updationTime);
-}
-
-Embedding _embeddingDeserialize(
-  Id id,
-  IsarReader reader,
-  List<int> offsets,
-  Map<Type, List<int>> allOffsets,
-) {
-  final object = Embedding(
-    embedding: reader.readDoubleList(offsets[0]) ?? [],
-    fileID: reader.readLong(offsets[1]),
-    model: _EmbeddingmodelValueEnumMap[reader.readByteOrNull(offsets[2])] ??
-        Model.onnxClip,
-    updationTime: reader.readLongOrNull(offsets[3]),
-  );
-  object.id = id;
-  return object;
-}
-
-P _embeddingDeserializeProp<P>(
-  IsarReader reader,
-  int propertyId,
-  int offset,
-  Map<Type, List<int>> allOffsets,
-) {
-  switch (propertyId) {
-    case 0:
-      return (reader.readDoubleList(offset) ?? []) as P;
-    case 1:
-      return (reader.readLong(offset)) as P;
-    case 2:
-      return (_EmbeddingmodelValueEnumMap[reader.readByteOrNull(offset)] ??
-          Model.onnxClip) as P;
-    case 3:
-      return (reader.readLongOrNull(offset)) as P;
-    default:
-      throw IsarError('Unknown property with id $propertyId');
-  }
-}
-
-const _EmbeddingmodelEnumValueMap = {
-  'onnxClip': 0,
-  'ggmlClip': 1,
-};
-const _EmbeddingmodelValueEnumMap = {
-  0: Model.onnxClip,
-  1: Model.ggmlClip,
-};
-
-Id _embeddingGetId(Embedding object) {
-  return object.id;
-}
-
-List<IsarLinkBase<dynamic>> _embeddingGetLinks(Embedding object) {
-  return [];
-}
-
-void _embeddingAttach(IsarCollection<dynamic> col, Id id, Embedding object) {
-  object.id = id;
-}
-
-extension EmbeddingByIndex on IsarCollection<Embedding> {
-  Future<Embedding?> getByModelFileID(Model model, int fileID) {
-    return getByIndex(r'unique_file_model_embedding', [model, fileID]);
-  }
-
-  Embedding? getByModelFileIDSync(Model model, int fileID) {
-    return getByIndexSync(r'unique_file_model_embedding', [model, fileID]);
-  }
-
-  Future<bool> deleteByModelFileID(Model model, int fileID) {
-    return deleteByIndex(r'unique_file_model_embedding', [model, fileID]);
-  }
-
-  bool deleteByModelFileIDSync(Model model, int fileID) {
-    return deleteByIndexSync(r'unique_file_model_embedding', [model, fileID]);
-  }
-
-  Future<List<Embedding?>> getAllByModelFileID(
-      List<Model> modelValues, List<int> fileIDValues) {
-    final len = modelValues.length;
-    assert(fileIDValues.length == len,
-        'All index values must have the same length');
-    final values = <List<dynamic>>[];
-    for (var i = 0; i < len; i++) {
-      values.add([modelValues[i], fileIDValues[i]]);
-    }
-
-    return getAllByIndex(r'unique_file_model_embedding', values);
-  }
-
-  List<Embedding?> getAllByModelFileIDSync(
-      List<Model> modelValues, List<int> fileIDValues) {
-    final len = modelValues.length;
-    assert(fileIDValues.length == len,
-        'All index values must have the same length');
-    final values = <List<dynamic>>[];
-    for (var i = 0; i < len; i++) {
-      values.add([modelValues[i], fileIDValues[i]]);
-    }
-
-    return getAllByIndexSync(r'unique_file_model_embedding', values);
-  }
-
-  Future<int> deleteAllByModelFileID(
-      List<Model> modelValues, List<int> fileIDValues) {
-    final len = modelValues.length;
-    assert(fileIDValues.length == len,
-        'All index values must have the same length');
-    final values = <List<dynamic>>[];
-    for (var i = 0; i < len; i++) {
-      values.add([modelValues[i], fileIDValues[i]]);
-    }
-
-    return deleteAllByIndex(r'unique_file_model_embedding', values);
-  }
-
-  int deleteAllByModelFileIDSync(
-      List<Model> modelValues, List<int> fileIDValues) {
-    final len = modelValues.length;
-    assert(fileIDValues.length == len,
-        'All index values must have the same length');
-    final values = <List<dynamic>>[];
-    for (var i = 0; i < len; i++) {
-      values.add([modelValues[i], fileIDValues[i]]);
-    }
-
-    return deleteAllByIndexSync(r'unique_file_model_embedding', values);
-  }
-
-  Future<Id> putByModelFileID(Embedding object) {
-    return putByIndex(r'unique_file_model_embedding', object);
-  }
-
-  Id putByModelFileIDSync(Embedding object, {bool saveLinks = true}) {
-    return putByIndexSync(r'unique_file_model_embedding', object,
-        saveLinks: saveLinks);
-  }
-
-  Future<List<Id>> putAllByModelFileID(List<Embedding> objects) {
-    return putAllByIndex(r'unique_file_model_embedding', objects);
-  }
-
-  List<Id> putAllByModelFileIDSync(List<Embedding> objects,
-      {bool saveLinks = true}) {
-    return putAllByIndexSync(r'unique_file_model_embedding', objects,
-        saveLinks: saveLinks);
-  }
-}
-
-extension EmbeddingQueryWhereSort
-    on QueryBuilder<Embedding, Embedding, QWhere> {
-  QueryBuilder<Embedding, Embedding, QAfterWhere> anyId() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(const IdWhereClause.any());
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhere> anyModelFileID() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(
-        const IndexWhereClause.any(indexName: r'unique_file_model_embedding'),
-      );
-    });
-  }
-}
-
-extension EmbeddingQueryWhere
-    on QueryBuilder<Embedding, Embedding, QWhereClause> {
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> idEqualTo(Id id) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IdWhereClause.between(
-        lower: id,
-        upper: id,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> idNotEqualTo(Id id) {
-    return QueryBuilder.apply(this, (query) {
-      if (query.whereSort == Sort.asc) {
-        return query
-            .addWhereClause(
-              IdWhereClause.lessThan(upper: id, includeUpper: false),
-            )
-            .addWhereClause(
-              IdWhereClause.greaterThan(lower: id, includeLower: false),
-            );
-      } else {
-        return query
-            .addWhereClause(
-              IdWhereClause.greaterThan(lower: id, includeLower: false),
-            )
-            .addWhereClause(
-              IdWhereClause.lessThan(upper: id, includeUpper: false),
-            );
-      }
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> idGreaterThan(Id id,
-      {bool include = false}) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(
-        IdWhereClause.greaterThan(lower: id, includeLower: include),
-      );
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> idLessThan(Id id,
-      {bool include = false}) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(
-        IdWhereClause.lessThan(upper: id, includeUpper: include),
-      );
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> idBetween(
-    Id lowerId,
-    Id upperId, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IdWhereClause.between(
-        lower: lowerId,
-        includeLower: includeLower,
-        upper: upperId,
-        includeUpper: includeUpper,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> modelEqualToAnyFileID(
-      Model model) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.equalTo(
-        indexName: r'unique_file_model_embedding',
-        value: [model],
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause>
-      modelNotEqualToAnyFileID(Model model) {
-    return QueryBuilder.apply(this, (query) {
-      if (query.whereSort == Sort.asc) {
-        return query
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'unique_file_model_embedding',
-              lower: [],
-              upper: [model],
-              includeUpper: false,
-            ))
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'unique_file_model_embedding',
-              lower: [model],
-              includeLower: false,
-              upper: [],
-            ));
-      } else {
-        return query
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'unique_file_model_embedding',
-              lower: [model],
-              includeLower: false,
-              upper: [],
-            ))
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'unique_file_model_embedding',
-              lower: [],
-              upper: [model],
-              includeUpper: false,
-            ));
-      }
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause>
-      modelGreaterThanAnyFileID(
-    Model model, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'unique_file_model_embedding',
-        lower: [model],
-        includeLower: include,
-        upper: [],
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> modelLessThanAnyFileID(
-    Model model, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'unique_file_model_embedding',
-        lower: [],
-        upper: [model],
-        includeUpper: include,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> modelBetweenAnyFileID(
-    Model lowerModel,
-    Model upperModel, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'unique_file_model_embedding',
-        lower: [lowerModel],
-        includeLower: includeLower,
-        upper: [upperModel],
-        includeUpper: includeUpper,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause> modelFileIDEqualTo(
-      Model model, int fileID) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.equalTo(
-        indexName: r'unique_file_model_embedding',
-        value: [model, fileID],
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause>
-      modelEqualToFileIDNotEqualTo(Model model, int fileID) {
-    return QueryBuilder.apply(this, (query) {
-      if (query.whereSort == Sort.asc) {
-        return query
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'unique_file_model_embedding',
-              lower: [model],
-              upper: [model, fileID],
-              includeUpper: false,
-            ))
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'unique_file_model_embedding',
-              lower: [model, fileID],
-              includeLower: false,
-              upper: [model],
-            ));
-      } else {
-        return query
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'unique_file_model_embedding',
-              lower: [model, fileID],
-              includeLower: false,
-              upper: [model],
-            ))
-            .addWhereClause(IndexWhereClause.between(
-              indexName: r'unique_file_model_embedding',
-              lower: [model],
-              upper: [model, fileID],
-              includeUpper: false,
-            ));
-      }
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause>
-      modelEqualToFileIDGreaterThan(
-    Model model,
-    int fileID, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'unique_file_model_embedding',
-        lower: [model, fileID],
-        includeLower: include,
-        upper: [model],
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause>
-      modelEqualToFileIDLessThan(
-    Model model,
-    int fileID, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'unique_file_model_embedding',
-        lower: [model],
-        upper: [model, fileID],
-        includeUpper: include,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterWhereClause>
-      modelEqualToFileIDBetween(
-    Model model,
-    int lowerFileID,
-    int upperFileID, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addWhereClause(IndexWhereClause.between(
-        indexName: r'unique_file_model_embedding',
-        lower: [model, lowerFileID],
-        includeLower: includeLower,
-        upper: [model, upperFileID],
-        includeUpper: includeUpper,
-      ));
-    });
-  }
-}
-
-extension EmbeddingQueryFilter
-    on QueryBuilder<Embedding, Embedding, QFilterCondition> {
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingElementEqualTo(
-    double value, {
-    double epsilon = Query.epsilon,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.equalTo(
-        property: r'embedding',
-        value: value,
-        epsilon: epsilon,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingElementGreaterThan(
-    double value, {
-    bool include = false,
-    double epsilon = Query.epsilon,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.greaterThan(
-        include: include,
-        property: r'embedding',
-        value: value,
-        epsilon: epsilon,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingElementLessThan(
-    double value, {
-    bool include = false,
-    double epsilon = Query.epsilon,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.lessThan(
-        include: include,
-        property: r'embedding',
-        value: value,
-        epsilon: epsilon,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingElementBetween(
-    double lower,
-    double upper, {
-    bool includeLower = true,
-    bool includeUpper = true,
-    double epsilon = Query.epsilon,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.between(
-        property: r'embedding',
-        lower: lower,
-        includeLower: includeLower,
-        upper: upper,
-        includeUpper: includeUpper,
-        epsilon: epsilon,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingLengthEqualTo(int length) {
-    return QueryBuilder.apply(this, (query) {
-      return query.listLength(
-        r'embedding',
-        length,
-        true,
-        length,
-        true,
-      );
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> embeddingIsEmpty() {
-    return QueryBuilder.apply(this, (query) {
-      return query.listLength(
-        r'embedding',
-        0,
-        true,
-        0,
-        true,
-      );
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingIsNotEmpty() {
-    return QueryBuilder.apply(this, (query) {
-      return query.listLength(
-        r'embedding',
-        0,
-        false,
-        999999,
-        true,
-      );
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingLengthLessThan(
-    int length, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.listLength(
-        r'embedding',
-        0,
-        true,
-        length,
-        include,
-      );
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingLengthGreaterThan(
-    int length, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.listLength(
-        r'embedding',
-        length,
-        include,
-        999999,
-        true,
-      );
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      embeddingLengthBetween(
-    int lower,
-    int upper, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.listLength(
-        r'embedding',
-        lower,
-        includeLower,
-        upper,
-        includeUpper,
-      );
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> fileIDEqualTo(
-      int value) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.equalTo(
-        property: r'fileID',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> fileIDGreaterThan(
-    int value, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.greaterThan(
-        include: include,
-        property: r'fileID',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> fileIDLessThan(
-    int value, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.lessThan(
-        include: include,
-        property: r'fileID',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> fileIDBetween(
-    int lower,
-    int upper, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.between(
-        property: r'fileID',
-        lower: lower,
-        includeLower: includeLower,
-        upper: upper,
-        includeUpper: includeUpper,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> idEqualTo(
-      Id value) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.equalTo(
-        property: r'id',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> idGreaterThan(
-    Id value, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.greaterThan(
-        include: include,
-        property: r'id',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> idLessThan(
-    Id value, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.lessThan(
-        include: include,
-        property: r'id',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> idBetween(
-    Id lower,
-    Id upper, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.between(
-        property: r'id',
-        lower: lower,
-        includeLower: includeLower,
-        upper: upper,
-        includeUpper: includeUpper,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> modelEqualTo(
-      Model value) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.equalTo(
-        property: r'model',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> modelGreaterThan(
-    Model value, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.greaterThan(
-        include: include,
-        property: r'model',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> modelLessThan(
-    Model value, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.lessThan(
-        include: include,
-        property: r'model',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> modelBetween(
-    Model lower,
-    Model upper, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.between(
-        property: r'model',
-        lower: lower,
-        includeLower: includeLower,
-        upper: upper,
-        includeUpper: includeUpper,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      updationTimeIsNull() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(const FilterCondition.isNull(
-        property: r'updationTime',
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      updationTimeIsNotNull() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(const FilterCondition.isNotNull(
-        property: r'updationTime',
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> updationTimeEqualTo(
-      int? value) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.equalTo(
-        property: r'updationTime',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      updationTimeGreaterThan(
-    int? value, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.greaterThan(
-        include: include,
-        property: r'updationTime',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition>
-      updationTimeLessThan(
-    int? value, {
-    bool include = false,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.lessThan(
-        include: include,
-        property: r'updationTime',
-        value: value,
-      ));
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterFilterCondition> updationTimeBetween(
-    int? lower,
-    int? upper, {
-    bool includeLower = true,
-    bool includeUpper = true,
-  }) {
-    return QueryBuilder.apply(this, (query) {
-      return query.addFilterCondition(FilterCondition.between(
-        property: r'updationTime',
-        lower: lower,
-        includeLower: includeLower,
-        upper: upper,
-        includeUpper: includeUpper,
-      ));
-    });
-  }
-}
-
-extension EmbeddingQueryObject
-    on QueryBuilder<Embedding, Embedding, QFilterCondition> {}
-
-extension EmbeddingQueryLinks
-    on QueryBuilder<Embedding, Embedding, QFilterCondition> {}
-
-extension EmbeddingQuerySortBy on QueryBuilder<Embedding, Embedding, QSortBy> {
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> sortByFileID() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'fileID', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> sortByFileIDDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'fileID', Sort.desc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> sortByModel() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'model', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> sortByModelDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'model', Sort.desc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> sortByUpdationTime() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'updationTime', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> sortByUpdationTimeDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'updationTime', Sort.desc);
-    });
-  }
-}
-
-extension EmbeddingQuerySortThenBy
-    on QueryBuilder<Embedding, Embedding, QSortThenBy> {
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> thenByFileID() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'fileID', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> thenByFileIDDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'fileID', Sort.desc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> thenById() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'id', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> thenByIdDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'id', Sort.desc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> thenByModel() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'model', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> thenByModelDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'model', Sort.desc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> thenByUpdationTime() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'updationTime', Sort.asc);
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QAfterSortBy> thenByUpdationTimeDesc() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addSortBy(r'updationTime', Sort.desc);
-    });
-  }
-}
-
-extension EmbeddingQueryWhereDistinct
-    on QueryBuilder<Embedding, Embedding, QDistinct> {
-  QueryBuilder<Embedding, Embedding, QDistinct> distinctByEmbedding() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addDistinctBy(r'embedding');
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QDistinct> distinctByFileID() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addDistinctBy(r'fileID');
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QDistinct> distinctByModel() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addDistinctBy(r'model');
-    });
-  }
-
-  QueryBuilder<Embedding, Embedding, QDistinct> distinctByUpdationTime() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addDistinctBy(r'updationTime');
-    });
-  }
-}
-
-extension EmbeddingQueryProperty
-    on QueryBuilder<Embedding, Embedding, QQueryProperty> {
-  QueryBuilder<Embedding, int, QQueryOperations> idProperty() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addPropertyName(r'id');
-    });
-  }
-
-  QueryBuilder<Embedding, List<double>, QQueryOperations> embeddingProperty() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addPropertyName(r'embedding');
-    });
-  }
-
-  QueryBuilder<Embedding, int, QQueryOperations> fileIDProperty() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addPropertyName(r'fileID');
-    });
-  }
-
-  QueryBuilder<Embedding, Model, QQueryOperations> modelProperty() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addPropertyName(r'model');
-    });
-  }
-
-  QueryBuilder<Embedding, int?, QQueryOperations> updationTimeProperty() {
-    return QueryBuilder.apply(this, (query) {
-      return query.addPropertyName(r'updationTime');
-    });
-  }
-}

+ 1 - 1
mobile/lib/models/file/file.dart

@@ -308,7 +308,7 @@ class EnteFile {
   @override
   @override
   String toString() {
   String toString() {
     return '''File(generatedID: $generatedID, localID: $localID, title: $title, 
     return '''File(generatedID: $generatedID, localID: $localID, title: $title, 
-      uploadedFileId: $uploadedFileID, modificationTime: $modificationTime, 
+      type: $fileType, uploadedFileId: $uploadedFileID, modificationTime: $modificationTime, 
       ownerID: $ownerID, collectionID: $collectionID, updationTime: $updationTime)''';
       ownerID: $ownerID, collectionID: $collectionID, updationTime: $updationTime)''';
   }
   }
 
 

+ 8 - 0
mobile/lib/service_locator.dart

@@ -1,4 +1,6 @@
 import "package:dio/dio.dart";
 import "package:dio/dio.dart";
+import "package:ente_cast/ente_cast.dart";
+import "package:ente_cast_normal/ente_cast_normal.dart";
 import "package:ente_feature_flag/ente_feature_flag.dart";
 import "package:ente_feature_flag/ente_feature_flag.dart";
 import "package:shared_preferences/shared_preferences.dart";
 import "package:shared_preferences/shared_preferences.dart";
 
 
@@ -26,3 +28,9 @@ FlagService get flagService {
   );
   );
   return _flagService!;
   return _flagService!;
 }
 }
+
+CastService? _castService;
+CastService get castService {
+  _castService ??= CastServiceImpl();
+  return _castService!;
+}

+ 1 - 1
mobile/lib/services/machine_learning/semantic_search/embedding_store.dart

@@ -19,7 +19,7 @@ class EmbeddingStore {
 
 
   static final EmbeddingStore instance = EmbeddingStore._privateConstructor();
   static final EmbeddingStore instance = EmbeddingStore._privateConstructor();
 
 
-  static const kEmbeddingsSyncTimeKey = "sync_time_embeddings_v2";
+  static const kEmbeddingsSyncTimeKey = "sync_time_embeddings_v3";
 
 
   final _logger = Logger("EmbeddingStore");
   final _logger = Logger("EmbeddingStore");
   final _dio = NetworkClient.instance.enteDio;
   final _dio = NetworkClient.instance.enteDio;

+ 1 - 1
mobile/lib/services/machine_learning/semantic_search/semantic_search_service.dart

@@ -72,8 +72,8 @@ class SemanticSearchService {
     _mlFramework = _currentModel == Model.onnxClip
     _mlFramework = _currentModel == Model.onnxClip
         ? ONNX(shouldDownloadOverMobileData)
         ? ONNX(shouldDownloadOverMobileData)
         : GGML(shouldDownloadOverMobileData);
         : GGML(shouldDownloadOverMobileData);
-    await EmbeddingsDB.instance.init();
     await EmbeddingStore.instance.init();
     await EmbeddingStore.instance.init();
+    await EmbeddingsDB.instance.init();
     await _loadEmbeddings();
     await _loadEmbeddings();
     Bus.instance.on<EmbeddingUpdatedEvent>().listen((event) {
     Bus.instance.on<EmbeddingUpdatedEvent>().listen((event) {
       _embeddingLoaderDebouncer.run(() async {
       _embeddingLoaderDebouncer.run(() async {

+ 1 - 1
mobile/lib/services/update_service.dart

@@ -16,7 +16,7 @@ class UpdateService {
   static final UpdateService instance = UpdateService._privateConstructor();
   static final UpdateService instance = UpdateService._privateConstructor();
   static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key";
   static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key";
   static const changeLogVersionKey = "update_change_log_key";
   static const changeLogVersionKey = "update_change_log_key";
-  static const currentChangeLogVersion = 18;
+  static const currentChangeLogVersion = 19;
 
 
   LatestVersionInfo? _latestVersion;
   LatestVersionInfo? _latestVersion;
   final _logger = Logger("UpdateService");
   final _logger = Logger("UpdateService");

+ 134 - 0
mobile/lib/ui/cast/auto.dart

@@ -0,0 +1,134 @@
+import "dart:io";
+
+import "package:ente_cast/ente_cast.dart";
+import "package:flutter/material.dart";
+import "package:photos/generated/l10n.dart";
+import "package:photos/service_locator.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/common/loading_widget.dart";
+import "package:photos/utils/dialog_util.dart";
+
+class AutoCastDialog extends StatefulWidget {
+  // async method that takes string as input
+  // and returns void
+  final void Function(String) onConnect;
+  AutoCastDialog(
+    this.onConnect, {
+    Key? key,
+  }) : super(key: key) {}
+
+  @override
+  State<AutoCastDialog> createState() => _AutoCastDialogState();
+}
+
+class _AutoCastDialogState extends State<AutoCastDialog> {
+  final bool doesUserExist = true;
+  final Set<Object> _isDeviceTapInProgress = {};
+
+  @override
+  Widget build(BuildContext context) {
+    final textStyle = getEnteTextTheme(context);
+    final AlertDialog alert = AlertDialog(
+      title: Text(
+        S.of(context).connectToDevice,
+        style: textStyle.largeBold,
+      ),
+      content: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Text(
+            S.of(context).autoCastDialogBody,
+            style: textStyle.bodyMuted,
+          ),
+          if (Platform.isIOS)
+            Text(
+              S.of(context).autoCastiOSPermission,
+              style: textStyle.bodyMuted,
+            ),
+          const SizedBox(height: 16),
+          FutureBuilder<List<(String, Object)>>(
+            future: castService.searchDevices(),
+            builder: (_, snapshot) {
+              if (snapshot.hasError) {
+                return Center(
+                  child: Text(
+                    'Error: ${snapshot.error.toString()}',
+                  ),
+                );
+              } else if (!snapshot.hasData) {
+                return const EnteLoadingWidget();
+              }
+
+              if (snapshot.data!.isEmpty) {
+                return Center(child: Text(S.of(context).noDeviceFound));
+              }
+
+              return Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: snapshot.data!.map((result) {
+                  final device = result.$2;
+                  final name = result.$1;
+                  return GestureDetector(
+                    onTap: () async {
+                      if (_isDeviceTapInProgress.contains(device)) {
+                        return;
+                      }
+                      setState(() {
+                        _isDeviceTapInProgress.add(device);
+                      });
+                      try {
+                        await _connectToYourApp(context, device);
+                        if (mounted) {
+                          setState(() {
+                            _isDeviceTapInProgress.remove(device);
+                          });
+                          Navigator.of(context).pop();
+                        }
+                      } catch (e) {
+                        if (mounted) {
+                          setState(() {
+                            _isDeviceTapInProgress.remove(device);
+                          });
+                          showGenericErrorDialog(context: context, error: e)
+                              .ignore();
+                        }
+                      }
+                    },
+                    child: Padding(
+                      padding: const EdgeInsets.symmetric(vertical: 8.0),
+                      child: Row(
+                        children: [
+                          Expanded(child: Text(name)),
+                          if (_isDeviceTapInProgress.contains(device))
+                            const EnteLoadingWidget(),
+                        ],
+                      ),
+                    ),
+                  );
+                }).toList(),
+              );
+            },
+          ),
+        ],
+      ),
+    );
+    return alert;
+  }
+
+  Future<void> _connectToYourApp(
+    BuildContext context,
+    Object castDevice,
+  ) async {
+    await castService.connectDevice(
+      context,
+      castDevice,
+      onMessage: (message) {
+        if (message.containsKey(CastMessageType.pairCode)) {
+          final code = message[CastMessageType.pairCode]!['code'];
+          widget.onConnect(code);
+        }
+      },
+    );
+  }
+}

+ 76 - 0
mobile/lib/ui/cast/choose.dart

@@ -0,0 +1,76 @@
+import "package:flutter/material.dart";
+import "package:photos/generated/l10n.dart";
+import "package:photos/l10n/l10n.dart";
+import "package:photos/theme/ente_theme.dart";
+import "package:photos/ui/components/buttons/button_widget.dart";
+import "package:photos/ui/components/models/button_type.dart";
+
+class CastChooseDialog extends StatefulWidget {
+  const CastChooseDialog({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  State<CastChooseDialog> createState() => _CastChooseDialogState();
+}
+
+class _CastChooseDialogState extends State<CastChooseDialog> {
+  final bool doesUserExist = true;
+
+  @override
+  Widget build(BuildContext context) {
+    final textStyle = getEnteTextTheme(context);
+    final AlertDialog alert = AlertDialog(
+      title: Text(
+        context.l10n.playOnTv,
+        style: textStyle.largeBold,
+      ),
+      content: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          const SizedBox(height: 8),
+          Text(
+            S.of(context).autoPairDesc,
+            style: textStyle.bodyMuted,
+          ),
+          const SizedBox(height: 12),
+          ButtonWidget(
+            labelText: S.of(context).autoPair,
+            icon: Icons.cast_outlined,
+            buttonType: ButtonType.neutral,
+            buttonSize: ButtonSize.large,
+            shouldStickToDarkTheme: true,
+            buttonAction: ButtonAction.first,
+            shouldSurfaceExecutionStates: false,
+            isInAlert: true,
+            onTap: () async {
+              Navigator.of(context).pop(ButtonAction.first);
+            },
+          ),
+          const SizedBox(height: 36),
+          Text(
+            S.of(context).manualPairDesc,
+            style: textStyle.bodyMuted,
+          ),
+          const SizedBox(height: 12),
+          ButtonWidget(
+            labelText: S.of(context).pairWithPin,
+            buttonType: ButtonType.neutral,
+            // icon for pairing with TV manually
+            icon: Icons.tv_outlined,
+            buttonSize: ButtonSize.large,
+            isInAlert: true,
+            onTap: () async {
+              Navigator.of(context).pop(ButtonAction.second);
+            },
+            shouldStickToDarkTheme: true,
+            buttonAction: ButtonAction.second,
+            shouldSurfaceExecutionStates: false,
+          ),
+        ],
+      ),
+    );
+    return alert;
+  }
+}

+ 38 - 0
mobile/lib/ui/common/popup_item.dart

@@ -0,0 +1,38 @@
+import 'package:flutter/material.dart';
+
+class EntePopupMenuItem<T> extends PopupMenuItem<T> {
+  final String label;
+  final IconData? icon;
+  final Widget? iconWidget;
+
+  EntePopupMenuItem(
+    this.label, {
+    required T value,
+    this.icon,
+    this.iconWidget,
+    Key? key,
+  })  : assert(
+          icon != null || iconWidget != null,
+          'Either icon or iconWidget must be provided.',
+        ),
+        assert(
+          !(icon != null && iconWidget != null),
+          'Only one of icon or iconWidget can be provided.',
+        ),
+        super(
+          value: value,
+          key: key,
+          child: Row(
+            children: [
+              if (iconWidget != null)
+                iconWidget
+              else if (icon != null)
+                Icon(icon),
+              const Padding(
+                padding: EdgeInsets.all(8),
+              ),
+              Text(label),
+            ],
+          ), // Initially empty, will be populated in build
+        );
+}

+ 4 - 3
mobile/lib/ui/components/bottom_action_bar/selection_action_button_widget.dart

@@ -132,14 +132,15 @@ class __BodyState extends State<_Body> {
     return maxWidth;
     return maxWidth;
   }
   }
 
 
+//Todo: this doesn't give the correct width of the word, make it right
   double computeWidthOfWord(String text, TextStyle style) {
   double computeWidthOfWord(String text, TextStyle style) {
     final textPainter = TextPainter(
     final textPainter = TextPainter(
       text: TextSpan(text: text, style: style),
       text: TextSpan(text: text, style: style),
       maxLines: 1,
       maxLines: 1,
       textDirection: TextDirection.ltr,
       textDirection: TextDirection.ltr,
-      textScaleFactor: MediaQuery.of(context).textScaleFactor,
+      textScaler: MediaQuery.textScalerOf(context),
     )..layout();
     )..layout();
-
-    return textPainter.size.width;
+//buffer of 8 added as width is shorter than actual text width
+    return textPainter.size.width + 8;
   }
   }
 }
 }

+ 28 - 29
mobile/lib/ui/notification/update/change_log_page.dart

@@ -1,5 +1,3 @@
-import "dart:async";
-
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import "package:photos/generated/l10n.dart";
 import "package:photos/generated/l10n.dart";
 import 'package:photos/services/update_service.dart';
 import 'package:photos/services/update_service.dart';
@@ -9,7 +7,6 @@ import 'package:photos/ui/components/divider_widget.dart';
 import 'package:photos/ui/components/models/button_type.dart';
 import 'package:photos/ui/components/models/button_type.dart';
 import 'package:photos/ui/components/title_bar_title_widget.dart';
 import 'package:photos/ui/components/title_bar_title_widget.dart';
 import 'package:photos/ui/notification/update/change_log_entry.dart';
 import 'package:photos/ui/notification/update/change_log_entry.dart';
-import "package:url_launcher/url_launcher_string.dart";
 
 
 class ChangeLogPage extends StatefulWidget {
 class ChangeLogPage extends StatefulWidget {
   const ChangeLogPage({
   const ChangeLogPage({
@@ -81,31 +78,31 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
                     const SizedBox(
                     const SizedBox(
                       height: 8,
                       height: 8,
                     ),
                     ),
-                    ButtonWidget(
-                      buttonType: ButtonType.trailingIconSecondary,
-                      buttonSize: ButtonSize.large,
-                      labelText: S.of(context).joinDiscord,
-                      icon: Icons.discord_outlined,
-                      iconColor: enteColorScheme.primary500,
-                      onTap: () async {
-                        unawaited(
-                          launchUrlString(
-                            "https://discord.com/invite/z2YVKkycX3",
-                            mode: LaunchMode.externalApplication,
-                          ),
-                        );
-                      },
-                    ),
                     // ButtonWidget(
                     // ButtonWidget(
                     //   buttonType: ButtonType.trailingIconSecondary,
                     //   buttonType: ButtonType.trailingIconSecondary,
                     //   buttonSize: ButtonSize.large,
                     //   buttonSize: ButtonSize.large,
-                    //   labelText: S.of(context).rateTheApp,
-                    //   icon: Icons.favorite_rounded,
+                    //   labelText: S.of(context).joinDiscord,
+                    //   icon: Icons.discord_outlined,
                     //   iconColor: enteColorScheme.primary500,
                     //   iconColor: enteColorScheme.primary500,
                     //   onTap: () async {
                     //   onTap: () async {
-                    //     await UpdateService.instance.launchReviewUrl();
+                    //     unawaited(
+                    //       launchUrlString(
+                    //         "https://discord.com/invite/z2YVKkycX3",
+                    //         mode: LaunchMode.externalApplication,
+                    //       ),
+                    //     );
                     //   },
                     //   },
                     // ),
                     // ),
+                    ButtonWidget(
+                      buttonType: ButtonType.trailingIconSecondary,
+                      buttonSize: ButtonSize.large,
+                      labelText: S.of(context).rateTheApp,
+                      icon: Icons.favorite_rounded,
+                      iconColor: enteColorScheme.primary500,
+                      onTap: () async {
+                        await UpdateService.instance.launchReviewUrl();
+                      },
+                    ),
                     const SizedBox(height: 8),
                     const SizedBox(height: 8),
                   ],
                   ],
                 ),
                 ),
@@ -122,18 +119,20 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
     final List<ChangeLogEntry> items = [];
     final List<ChangeLogEntry> items = [];
     items.addAll([
     items.addAll([
       ChangeLogEntry(
       ChangeLogEntry(
-        "Improved Performance for Large Galleries ✨",
-        'We\'ve made significant improvements to how quickly galleries load and'
-            ' with less stutter, especially for those with a lot of photos and videos.',
+        "Cast albums to TV ✨",
+        "View a slideshow of your albums on any big screen! Open an album and click on the Cast button to get started.",
+      ),
+      ChangeLogEntry(
+        "Organize shared photos",
+        "You can now add shared items to your favorites to any of your personal albums. Ente will create a copy that is fully owned by you and can be organized to your liking.",
       ),
       ),
       ChangeLogEntry(
       ChangeLogEntry(
-        "Enhanced Functionality for Video Backups",
-        'Even if video backups are disabled, you can now manually upload individual videos.',
+        "Download multiple items",
+        "You can now download multiple items to your gallery at once. Select the items you want to download and click on the download button.",
       ),
       ),
       ChangeLogEntry(
       ChangeLogEntry(
-        "Bug Fixes",
-        'Many a bugs were squashed in this release.\n'
-            '\nIf you run into any, please write to team@ente.io, or let us know on Discord! 🙏',
+        "Performance improvements",
+        "This release also brings in major changes that should improve responsiveness. If you discover room for improvement, please let us know!",
       ),
       ),
     ]);
     ]);
 
 

+ 67 - 31
mobile/lib/ui/viewer/actions/file_selection_actions_widget.dart

@@ -3,6 +3,7 @@ import "dart:async";
 import 'package:fast_base58/fast_base58.dart';
 import 'package:fast_base58/fast_base58.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter/services.dart';
+import "package:logging/logging.dart";
 import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
 import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
 import 'package:photos/core/configuration.dart';
 import 'package:photos/core/configuration.dart';
 import "package:photos/generated/l10n.dart";
 import "package:photos/generated/l10n.dart";
@@ -30,6 +31,8 @@ import 'package:photos/ui/sharing/manage_links_widget.dart';
 import "package:photos/ui/tools/collage/collage_creator_page.dart";
 import "package:photos/ui/tools/collage/collage_creator_page.dart";
 import "package:photos/ui/viewer/location/update_location_data_widget.dart";
 import "package:photos/ui/viewer/location/update_location_data_widget.dart";
 import 'package:photos/utils/delete_file_util.dart';
 import 'package:photos/utils/delete_file_util.dart';
+import "package:photos/utils/dialog_util.dart";
+import "package:photos/utils/file_download_util.dart";
 import 'package:photos/utils/magic_util.dart';
 import 'package:photos/utils/magic_util.dart';
 import 'package:photos/utils/navigation_util.dart';
 import 'package:photos/utils/navigation_util.dart';
 import "package:photos/utils/share_util.dart";
 import "package:photos/utils/share_util.dart";
@@ -56,6 +59,7 @@ class FileSelectionActionsWidget extends StatefulWidget {
 
 
 class _FileSelectionActionsWidgetState
 class _FileSelectionActionsWidgetState
     extends State<FileSelectionActionsWidget> {
     extends State<FileSelectionActionsWidget> {
+  static final _logger = Logger("FileSelectionActionsWidget");
   late int currentUserID;
   late int currentUserID;
   late FilesSplit split;
   late FilesSplit split;
   late CollectionActions collectionActions;
   late CollectionActions collectionActions;
@@ -115,6 +119,8 @@ class _FileSelectionActionsWidgetState
         !widget.selectedFiles.files.any(
         !widget.selectedFiles.files.any(
           (element) => element.fileType == FileType.video,
           (element) => element.fileType == FileType.video,
         );
         );
+    final showDownloadOption =
+        widget.selectedFiles.files.any((element) => element.localID == null);
 
 
     //To animate adding and removing of [SelectedActionButton], add all items
     //To animate adding and removing of [SelectedActionButton], add all items
     //and set [shouldShow] to false for items that should not be shown and true
     //and set [shouldShow] to false for items that should not be shown and true
@@ -367,6 +373,16 @@ class _FileSelectionActionsWidgetState
       );
       );
     }
     }
 
 
+    if (showDownloadOption) {
+      items.add(
+        SelectionActionButton(
+          labelText: S.of(context).download,
+          icon: Icons.cloud_download_outlined,
+          onTap: () => _download(widget.selectedFiles.files.toList()),
+        ),
+      );
+    }
+
     items.add(
     items.add(
       SelectionActionButton(
       SelectionActionButton(
         labelText: S.of(context).share,
         labelText: S.of(context).share,
@@ -379,41 +395,36 @@ class _FileSelectionActionsWidgetState
       ),
       ),
     );
     );
 
 
-    if (items.isNotEmpty) {
-      final scrollController = ScrollController();
-      // h4ck: https://github.com/flutter/flutter/issues/57920#issuecomment-893970066
-      return MediaQuery(
-        data: MediaQuery.of(context).removePadding(removeBottom: true),
-        child: SafeArea(
-          child: Scrollbar(
-            radius: const Radius.circular(1),
-            thickness: 2,
-            controller: scrollController,
-            thumbVisibility: true,
-            child: SingleChildScrollView(
-              physics: const BouncingScrollPhysics(
-                decelerationRate: ScrollDecelerationRate.fast,
-              ),
-              scrollDirection: Axis.horizontal,
-              child: Container(
-                padding: const EdgeInsets.only(bottom: 24),
-                child: Row(
-                  crossAxisAlignment: CrossAxisAlignment.start,
-                  children: [
-                    const SizedBox(width: 4),
-                    ...items,
-                    const SizedBox(width: 4),
-                  ],
-                ),
+    final scrollController = ScrollController();
+    // h4ck: https://github.com/flutter/flutter/issues/57920#issuecomment-893970066
+    return MediaQuery(
+      data: MediaQuery.of(context).removePadding(removeBottom: true),
+      child: SafeArea(
+        child: Scrollbar(
+          radius: const Radius.circular(1),
+          thickness: 2,
+          controller: scrollController,
+          thumbVisibility: true,
+          child: SingleChildScrollView(
+            physics: const BouncingScrollPhysics(
+              decelerationRate: ScrollDecelerationRate.fast,
+            ),
+            scrollDirection: Axis.horizontal,
+            child: Container(
+              padding: const EdgeInsets.only(bottom: 24),
+              child: Row(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  const SizedBox(width: 4),
+                  ...items,
+                  const SizedBox(width: 4),
+                ],
               ),
               ),
             ),
             ),
           ),
           ),
         ),
         ),
-      );
-    } else {
-      // TODO: Return "Select All" here
-      return const SizedBox.shrink();
-    }
+      ),
+    );
   }
   }
 
 
   Future<void> _moveFiles() async {
   Future<void> _moveFiles() async {
@@ -647,4 +658,29 @@ class _FileSelectionActionsWidgetState
       widget.selectedFiles.clearAll();
       widget.selectedFiles.clearAll();
     }
     }
   }
   }
+
+  Future<void> _download(List<EnteFile> files) async {
+    final dialog = createProgressDialog(
+      context,
+      S.of(context).downloading,
+      isDismissible: true,
+    );
+    await dialog.show();
+    try {
+      final futures = <Future>[];
+      for (final file in files) {
+        if (file.localID == null) {
+          futures.add(downloadToGallery(file));
+        }
+      }
+      await Future.wait(futures);
+      await dialog.hide();
+      widget.selectedFiles.clearAll();
+      showToast(context, S.of(context).filesSavedToGallery);
+    } catch (e) {
+      _logger.warning("Failed to save files", e);
+      await dialog.hide();
+      await showGenericErrorDialog(context: context, error: e);
+    }
+  }
 }
 }

+ 3 - 92
mobile/lib/ui/viewer/file/file_app_bar.dart

@@ -4,30 +4,23 @@ import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:logging/logging.dart';
 import 'package:logging/logging.dart';
 import 'package:media_extension/media_extension.dart';
 import 'package:media_extension/media_extension.dart';
-import 'package:path/path.dart' as file_path;
-import 'package:photo_manager/photo_manager.dart';
-import 'package:photos/core/event_bus.dart';
-import 'package:photos/db/files_db.dart';
-import 'package:photos/events/local_photos_updated_event.dart';
 import "package:photos/generated/l10n.dart";
 import "package:photos/generated/l10n.dart";
 import "package:photos/l10n/l10n.dart";
 import "package:photos/l10n/l10n.dart";
 import "package:photos/models/file/extensions/file_props.dart";
 import "package:photos/models/file/extensions/file_props.dart";
 import 'package:photos/models/file/file.dart';
 import 'package:photos/models/file/file.dart';
 import 'package:photos/models/file/file_type.dart';
 import 'package:photos/models/file/file_type.dart';
 import 'package:photos/models/file/trash_file.dart';
 import 'package:photos/models/file/trash_file.dart';
-import 'package:photos/models/ignored_file.dart';
 import "package:photos/models/metadata/common_keys.dart";
 import "package:photos/models/metadata/common_keys.dart";
 import 'package:photos/models/selected_files.dart';
 import 'package:photos/models/selected_files.dart';
 import "package:photos/service_locator.dart";
 import "package:photos/service_locator.dart";
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/hidden_service.dart';
 import 'package:photos/services/hidden_service.dart';
-import 'package:photos/services/ignored_files_service.dart';
-import 'package:photos/services/local_sync_service.dart';
 import 'package:photos/ui/collections/collection_action_sheet.dart';
 import 'package:photos/ui/collections/collection_action_sheet.dart';
 import 'package:photos/ui/viewer/file/custom_app_bar.dart';
 import 'package:photos/ui/viewer/file/custom_app_bar.dart';
 import "package:photos/ui/viewer/file_details/favorite_widget.dart";
 import "package:photos/ui/viewer/file_details/favorite_widget.dart";
 import "package:photos/ui/viewer/file_details/upload_icon_widget.dart";
 import "package:photos/ui/viewer/file_details/upload_icon_widget.dart";
 import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/dialog_util.dart';
+import "package:photos/utils/file_download_util.dart";
 import 'package:photos/utils/file_util.dart';
 import 'package:photos/utils/file_util.dart';
 import "package:photos/utils/magic_util.dart";
 import "package:photos/utils/magic_util.dart";
 import 'package:photos/utils/toast_util.dart';
 import 'package:photos/utils/toast_util.dart';
@@ -165,7 +158,7 @@ class FileAppBarState extends State<FileAppBar> {
               Icon(
               Icon(
                 Platform.isAndroid
                 Platform.isAndroid
                     ? Icons.download
                     ? Icons.download
-                    : CupertinoIcons.cloud_download,
+                    : Icons.cloud_download_outlined,
                 color: Theme.of(context).iconTheme.color,
                 color: Theme.of(context).iconTheme.color,
               ),
               ),
               const Padding(
               const Padding(
@@ -330,98 +323,16 @@ class FileAppBarState extends State<FileAppBar> {
     );
     );
     await dialog.show();
     await dialog.show();
     try {
     try {
-      final FileType type = file.fileType;
-      final bool downloadLivePhotoOnDroid =
-          type == FileType.livePhoto && Platform.isAndroid;
-      AssetEntity? savedAsset;
-      final File? fileToSave = await getFile(file);
-      //Disabling notifications for assets changing to insert the file into
-      //files db before triggering a sync.
-      await PhotoManager.stopChangeNotify();
-      if (type == FileType.image) {
-        savedAsset = await PhotoManager.editor
-            .saveImageWithPath(fileToSave!.path, title: file.title!);
-      } else if (type == FileType.video) {
-        savedAsset = await PhotoManager.editor
-            .saveVideo(fileToSave!, title: file.title!);
-      } else if (type == FileType.livePhoto) {
-        final File? liveVideoFile =
-            await getFileFromServer(file, liveVideo: true);
-        if (liveVideoFile == null) {
-          throw AssertionError("Live video can not be null");
-        }
-        if (downloadLivePhotoOnDroid) {
-          await _saveLivePhotoOnDroid(fileToSave!, liveVideoFile, file);
-        } else {
-          savedAsset = await PhotoManager.editor.darwin.saveLivePhoto(
-            imageFile: fileToSave!,
-            videoFile: liveVideoFile,
-            title: file.title!,
-          );
-        }
-      }
-
-      if (savedAsset != null) {
-        file.localID = savedAsset.id;
-        await FilesDB.instance.insert(file);
-        Bus.instance.fire(
-          LocalPhotosUpdatedEvent(
-            [file],
-            source: "download",
-          ),
-        );
-      } else if (!downloadLivePhotoOnDroid && savedAsset == null) {
-        _logger.severe('Failed to save assert of type $type');
-      }
+      await downloadToGallery(file);
       showToast(context, S.of(context).fileSavedToGallery);
       showToast(context, S.of(context).fileSavedToGallery);
       await dialog.hide();
       await dialog.hide();
     } catch (e) {
     } catch (e) {
       _logger.warning("Failed to save file", e);
       _logger.warning("Failed to save file", e);
       await dialog.hide();
       await dialog.hide();
       await showGenericErrorDialog(context: context, error: e);
       await showGenericErrorDialog(context: context, error: e);
-    } finally {
-      await PhotoManager.startChangeNotify();
-      LocalSyncService.instance.checkAndSync().ignore();
     }
     }
   }
   }
 
 
-  Future<void> _saveLivePhotoOnDroid(
-    File image,
-    File video,
-    EnteFile enteFile,
-  ) async {
-    debugPrint("Downloading LivePhoto on Droid");
-    AssetEntity? savedAsset = await (PhotoManager.editor
-        .saveImageWithPath(image.path, title: enteFile.title!));
-    if (savedAsset == null) {
-      throw Exception("Failed to save image of live photo");
-    }
-    IgnoredFile ignoreVideoFile = IgnoredFile(
-      savedAsset.id,
-      savedAsset.title ?? '',
-      savedAsset.relativePath ?? 'remoteDownload',
-      "remoteDownload",
-    );
-    await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
-    final videoTitle = file_path.basenameWithoutExtension(enteFile.title!) +
-        file_path.extension(video.path);
-    savedAsset = (await (PhotoManager.editor.saveVideo(
-      video,
-      title: videoTitle,
-    )));
-    if (savedAsset == null) {
-      throw Exception("Failed to save video of live photo");
-    }
-
-    ignoreVideoFile = IgnoredFile(
-      savedAsset.id,
-      savedAsset.title ?? videoTitle,
-      savedAsset.relativePath ?? 'remoteDownload',
-      "remoteDownload",
-    );
-    await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
-  }
-
   Future<void> _setAs(EnteFile file) async {
   Future<void> _setAs(EnteFile file) async {
     final dialog = createProgressDialog(context, S.of(context).pleaseWait);
     final dialog = createProgressDialog(context, S.of(context).pleaseWait);
     await dialog.show();
     await dialog.show();

+ 208 - 255
mobile/lib/ui/viewer/gallery/gallery_app_bar_widget.dart

@@ -24,6 +24,9 @@ import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/sync_service.dart';
 import 'package:photos/services/sync_service.dart';
 import 'package:photos/services/update_service.dart';
 import 'package:photos/services/update_service.dart';
 import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
 import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
+import "package:photos/ui/cast/auto.dart";
+import "package:photos/ui/cast/choose.dart";
+import "package:photos/ui/common/popup_item.dart";
 import 'package:photos/ui/components/action_sheet_widget.dart';
 import 'package:photos/ui/components/action_sheet_widget.dart';
 import 'package:photos/ui/components/buttons/button_widget.dart';
 import 'package:photos/ui/components/buttons/button_widget.dart';
 import 'package:photos/ui/components/models/button_type.dart';
 import 'package:photos/ui/components/models/button_type.dart';
@@ -319,263 +322,136 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
         ),
         ),
       );
       );
     }
     }
-    final List<PopupMenuItem<AlbumPopupAction>> items = [];
-    if (galleryType.canRename()) {
-      items.add(
-        PopupMenuItem(
-          value: AlbumPopupAction.rename,
-          child: Row(
-            children: [
-              Icon(isQuickLink ? Icons.photo_album_outlined : Icons.edit),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(
-                isQuickLink
-                    ? S.of(context).convertToAlbum
-                    : S.of(context).renameAlbum,
-              ),
-            ],
+
+    if (widget.collection != null && castService.isSupported) {
+      actions.add(
+        Tooltip(
+          message: "Cast album",
+          child: IconButton(
+            icon: castService.getActiveSessions().isNotEmpty
+                ? const Icon(Icons.cast_connected_rounded)
+                : const Icon(Icons.cast_outlined),
+            onPressed: () async {
+              await _castChoiceDialog();
+              if (mounted) {
+                setState(() {});
+              }
+            },
           ),
           ),
         ),
         ),
       );
       );
     }
     }
-    if (galleryType.canSetCover()) {
-      items.add(
-        PopupMenuItem(
+    final List<EntePopupMenuItem<AlbumPopupAction>> items = [];
+    items.addAll([
+      if (galleryType.canRename())
+        EntePopupMenuItem(
+          isQuickLink
+              ? S.of(context).convertToAlbum
+              : S.of(context).renameAlbum,
+          value: AlbumPopupAction.rename,
+          icon: isQuickLink ? Icons.photo_album_outlined : Icons.edit,
+        ),
+      if (galleryType.canSetCover())
+        EntePopupMenuItem(
+          S.of(context).setCover,
           value: AlbumPopupAction.setCover,
           value: AlbumPopupAction.setCover,
-          child: Row(
-            children: [
-              const Icon(Icons.image_outlined),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(S.of(context).setCover),
-            ],
-          ),
+          icon: Icons.image_outlined,
         ),
         ),
-      );
-    }
-    if (galleryType.showMap()) {
-      items.add(
-        PopupMenuItem(
+      if (galleryType.showMap())
+        EntePopupMenuItem(
+          S.of(context).map,
           value: AlbumPopupAction.map,
           value: AlbumPopupAction.map,
-          child: Row(
-            children: [
-              const Icon(Icons.map_outlined),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(S.of(context).map),
-            ],
-          ),
+          icon: Icons.map_outlined,
         ),
         ),
-      );
-    }
-
-    if (galleryType.canSort()) {
-      items.add(
-        PopupMenuItem(
+      if (galleryType.canSort())
+        EntePopupMenuItem(
+          S.of(context).sortAlbumsBy,
           value: AlbumPopupAction.sort,
           value: AlbumPopupAction.sort,
-          child: Row(
-            children: [
-              const Icon(Icons.sort_outlined),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(
-                S.of(context).sortAlbumsBy,
-              ),
-            ],
-          ),
+          icon: Icons.sort_outlined,
         ),
         ),
-      );
-    }
-
-    if (galleryType == GalleryType.uncategorized) {
-      items.add(
-        PopupMenuItem(
+      if (galleryType == GalleryType.uncategorized)
+        EntePopupMenuItem(
+          S.of(context).cleanUncategorized,
           value: AlbumPopupAction.cleanUncategorized,
           value: AlbumPopupAction.cleanUncategorized,
-          child: Row(
-            children: [
-              const Icon(Icons.crop_original_outlined),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(S.of(context).cleanUncategorized),
-            ],
-          ),
+          icon: Icons.crop_original_outlined,
         ),
         ),
-      );
-    }
-    if (galleryType.canPin()) {
-      items.add(
-        PopupMenuItem(
+      if (galleryType.canPin())
+        EntePopupMenuItem(
+          widget.collection!.isPinned
+              ? S.of(context).unpinAlbum
+              : S.of(context).pinAlbum,
           value: AlbumPopupAction.pinAlbum,
           value: AlbumPopupAction.pinAlbum,
-          child: Row(
-            children: [
-              widget.collection!.isPinned
-                  ? const Icon(CupertinoIcons.pin_slash)
-                  : Transform.rotate(
-                      angle: 45 * math.pi / 180, // rotate by 45 degrees
-                      child: const Icon(CupertinoIcons.pin),
-                    ),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(
-                widget.collection!.isPinned
-                    ? S.of(context).unpinAlbum
-                    : S.of(context).pinAlbum,
-              ),
-            ],
-          ),
+          iconWidget: widget.collection!.isPinned
+              ? const Icon(CupertinoIcons.pin_slash)
+              : Transform.rotate(
+                  angle: 45 * math.pi / 180, // rotate by 45 degrees
+                  child: const Icon(CupertinoIcons.pin),
+                ),
         ),
         ),
-      );
-    }
+    ]);
     final bool isArchived = widget.collection?.isArchived() ?? false;
     final bool isArchived = widget.collection?.isArchived() ?? false;
     final bool isHidden = widget.collection?.isHidden() ?? false;
     final bool isHidden = widget.collection?.isHidden() ?? false;
-    // Do not show archive option for favorite collection. If collection is
-    // already archived, allow user to unarchive that collection.
-    if (isArchived || (galleryType.canArchive() && !isHidden)) {
-      items.add(
-        PopupMenuItem(
-          value: AlbumPopupAction.ownedArchive,
-          child: Row(
-            children: [
-              Icon(isArchived ? Icons.unarchive : Icons.archive_outlined),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(
-                isArchived
-                    ? S.of(context).unarchiveAlbum
-                    : S.of(context).archiveAlbum,
-              ),
-            ],
+
+    items.addAll(
+      [
+        // Do not show archive option for favorite collection. If collection is
+        // already archived, allow user to unarchive that collection.
+        if (isArchived || (galleryType.canArchive() && !isHidden))
+          EntePopupMenuItem(
+            value: AlbumPopupAction.ownedArchive,
+            isArchived
+                ? S.of(context).unarchiveAlbum
+                : S.of(context).archiveAlbum,
+            icon: isArchived ? Icons.unarchive : Icons.archive_outlined,
           ),
           ),
-        ),
-      );
-    }
-    if (!isArchived && galleryType.canHide()) {
-      items.add(
-        PopupMenuItem(
-          value: AlbumPopupAction.ownedHide,
-          child: Row(
-            children: [
-              Icon(
-                isHidden
-                    ? Icons.visibility_outlined
-                    : Icons.visibility_off_outlined,
-              ),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(
-                isHidden ? S.of(context).unhide : S.of(context).hide,
-              ),
-            ],
+        if (!isArchived && galleryType.canHide())
+          EntePopupMenuItem(
+            value: AlbumPopupAction.ownedHide,
+            isHidden ? S.of(context).unhide : S.of(context).hide,
+            icon: isHidden
+                ? Icons.visibility_outlined
+                : Icons.visibility_off_outlined,
           ),
           ),
-        ),
-      );
-    }
-    if (widget.collection != null && isInternalUser) {
-      items.add(
-        PopupMenuItem(
-          value: AlbumPopupAction.playOnTv,
-          child: Row(
-            children: [
-              const Icon(Icons.tv_outlined),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(context.l10n.playOnTv),
-            ],
+        if (widget.collection != null && isInternalUser)
+          EntePopupMenuItem(
+            value: AlbumPopupAction.playOnTv,
+            context.l10n.playOnTv,
+            icon: Icons.tv_outlined,
           ),
           ),
-        ),
-      );
-    }
-
-    if (galleryType.canDelete()) {
-      items.add(
-        PopupMenuItem(
-          value: isQuickLink
-              ? AlbumPopupAction.removeLink
-              : AlbumPopupAction.delete,
-          child: Row(
-            children: [
-              Icon(
-                isQuickLink
-                    ? Icons.remove_circle_outline
-                    : Icons.delete_outline,
-              ),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(
-                isQuickLink
-                    ? S.of(context).removeLink
-                    : S.of(context).deleteAlbum,
-              ),
-            ],
+        if (galleryType.canDelete())
+          EntePopupMenuItem(
+            isQuickLink ? S.of(context).removeLink : S.of(context).deleteAlbum,
+            value: isQuickLink
+                ? AlbumPopupAction.removeLink
+                : AlbumPopupAction.delete,
+            icon: isQuickLink
+                ? Icons.remove_circle_outline
+                : Icons.delete_outline,
           ),
           ),
-        ),
-      );
-    }
-
-    if (galleryType == GalleryType.sharedCollection) {
-      final bool hasShareeArchived = widget.collection!.hasShareeArchived();
-      items.add(
-        PopupMenuItem(
-          value: AlbumPopupAction.sharedArchive,
-          child: Row(
-            children: [
-              Icon(
-                hasShareeArchived ? Icons.unarchive : Icons.archive_outlined,
-              ),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(
-                hasShareeArchived
-                    ? S.of(context).unarchiveAlbum
-                    : S.of(context).archiveAlbum,
-              ),
-            ],
+        if (galleryType == GalleryType.sharedCollection)
+          EntePopupMenuItem(
+            widget.collection!.hasShareeArchived()
+                ? S.of(context).unarchiveAlbum
+                : S.of(context).archiveAlbum,
+            value: AlbumPopupAction.sharedArchive,
+            icon: widget.collection!.hasShareeArchived()
+                ? Icons.unarchive
+                : Icons.archive_outlined,
           ),
           ),
-        ),
-      );
-      items.add(
-        PopupMenuItem(
-          value: AlbumPopupAction.leave,
-          child: Row(
-            children: [
-              const Icon(Icons.logout),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(S.of(context).leaveAlbum),
-            ],
+        if (galleryType == GalleryType.sharedCollection)
+          EntePopupMenuItem(
+            S.of(context).leaveAlbum,
+            value: AlbumPopupAction.leave,
+            icon: Icons.logout,
           ),
           ),
-        ),
-      );
-    }
-    if (galleryType == GalleryType.localFolder) {
-      items.add(
-        PopupMenuItem(
-          value: AlbumPopupAction.freeUpSpace,
-          child: Row(
-            children: [
-              const Icon(Icons.delete_sweep_outlined),
-              const Padding(
-                padding: EdgeInsets.all(8),
-              ),
-              Text(S.of(context).freeUpDeviceSpace),
-            ],
+        if (galleryType == GalleryType.localFolder)
+          EntePopupMenuItem(
+            S.of(context).freeUpDeviceSpace,
+            value: AlbumPopupAction.freeUpSpace,
+            icon: Icons.delete_sweep_outlined,
           ),
           ),
-        ),
-      );
-    }
+      ],
+    );
     if (items.isNotEmpty) {
     if (items.isNotEmpty) {
       actions.add(
       actions.add(
         PopupMenuButton(
         PopupMenuButton(
@@ -603,7 +479,7 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
             } else if (value == AlbumPopupAction.leave) {
             } else if (value == AlbumPopupAction.leave) {
               await _leaveAlbum(context);
               await _leaveAlbum(context);
             } else if (value == AlbumPopupAction.playOnTv) {
             } else if (value == AlbumPopupAction.playOnTv) {
-              await castAlbum();
+              await _castChoiceDialog();
             } else if (value == AlbumPopupAction.freeUpSpace) {
             } else if (value == AlbumPopupAction.freeUpSpace) {
               await _deleteBackedUpFiles(context);
               await _deleteBackedUpFiles(context);
             } else if (value == AlbumPopupAction.setCover) {
             } else if (value == AlbumPopupAction.setCover) {
@@ -838,10 +714,56 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
     setState(() {});
     setState(() {});
   }
   }
 
 
-  Future<void> castAlbum() async {
+  Future<void> _castChoiceDialog() async {
     final gw = CastGateway(NetworkClient.instance.enteDio);
     final gw = CastGateway(NetworkClient.instance.enteDio);
+    if (castService.getActiveSessions().isNotEmpty) {
+      await showChoiceDialog(
+        context,
+        title: S.of(context).stopCastingTitle,
+        firstButtonLabel: S.of(context).yes,
+        secondButtonLabel: S.of(context).no,
+        body: S.of(context).stopCastingBody,
+        firstButtonOnTap: () async {
+          gw.revokeAllTokens().ignore();
+          await castService.closeActiveCasts();
+        },
+      );
+      return;
+    }
+
     // stop any existing cast session
     // stop any existing cast session
     gw.revokeAllTokens().ignore();
     gw.revokeAllTokens().ignore();
+    final result = await showDialog<ButtonAction?>(
+      context: context,
+      barrierDismissible: true,
+      builder: (BuildContext context) {
+        return const CastChooseDialog();
+      },
+    );
+    if (result == null) {
+      return;
+    }
+    // wait to allow the dialog to close
+    await Future.delayed(const Duration(milliseconds: 100));
+    if (result == ButtonAction.first) {
+      await showDialog(
+        context: context,
+        barrierDismissible: true,
+        builder: (BuildContext bContext) {
+          return AutoCastDialog(
+            (device) async {
+              await _castPair(bContext, gw, device);
+            },
+          );
+        },
+      );
+    }
+    if (result == ButtonAction.second) {
+      await _pairWithPin(gw, '');
+    }
+  }
+
+  Future<void> _pairWithPin(CastGateway gw, String code) async {
     await showTextInputDialog(
     await showTextInputDialog(
       context,
       context,
       title: context.l10n.playOnTv,
       title: context.l10n.playOnTv,
@@ -849,28 +771,59 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
       submitButtonLabel: S.of(context).pair,
       submitButtonLabel: S.of(context).pair,
       textInputType: TextInputType.streetAddress,
       textInputType: TextInputType.streetAddress,
       hintText: context.l10n.deviceCodeHint,
       hintText: context.l10n.deviceCodeHint,
+      showOnlyLoadingState: true,
+      alwaysShowSuccessState: false,
+      initialValue: code,
       onSubmit: (String text) async {
       onSubmit: (String text) async {
-        try {
-          final code = text.trim();
-          final String? publicKey = await gw.getPublicKey(code);
-          if (publicKey == null) {
-            showToast(context, S.of(context).deviceNotFound);
-            return;
-          }
-          final String castToken = const Uuid().v4().toString();
-          final castPayload = CollectionsService.instance
-              .getCastData(castToken, widget.collection!, publicKey);
-          await gw.publishCastPayload(
-            code,
-            castPayload,
-            widget.collection!.id,
-            castToken,
-          );
-        } catch (e, s) {
-          _logger.severe("Failed to cast album", e, s);
-          await showGenericErrorDialog(context: context, error: e);
+        final bool paired = await _castPair(context, gw, text);
+        if (!paired) {
+          Future.delayed(Duration.zero, () => _pairWithPin(gw, code));
         }
         }
       },
       },
     );
     );
   }
   }
+
+  String lastCode = '';
+  Future<bool> _castPair(
+      BuildContext bContext, CastGateway gw, String code) async {
+    try {
+      if (lastCode == code) {
+        return false;
+      }
+      lastCode = code;
+      _logger.info("Casting album to device with code $code");
+      final String? publicKey = await gw.getPublicKey(code);
+      if (publicKey == null) {
+        showToast(context, S.of(context).deviceNotFound);
+
+        return false;
+      }
+      final String castToken = const Uuid().v4().toString();
+      final castPayload = CollectionsService.instance
+          .getCastData(castToken, widget.collection!, publicKey);
+      _logger.info("Casting album with token $castToken");
+      await gw.publishCastPayload(
+        code,
+        castPayload,
+        widget.collection!.id,
+        castToken,
+      );
+      _logger.info("Casted album with token $castToken");
+      // showToast(bContext, S.of(context).pairingComplete);
+      return true;
+    } catch (e, s) {
+      lastCode = '';
+      _logger.severe("Failed to cast album", e, s);
+      if (e is CastIPMismatchException) {
+        await showErrorDialog(
+          context,
+          S.of(context).castIPMismatchTitle,
+          S.of(context).castIPMismatchBody,
+        );
+      } else {
+        await showGenericErrorDialog(context: bContext, error: e);
+      }
+      return false;
+    }
+  }
 }
 }

+ 100 - 0
mobile/lib/utils/file_download_util.dart

@@ -4,14 +4,23 @@ import "package:computer/computer.dart";
 import 'package:dio/dio.dart';
 import 'package:dio/dio.dart';
 import "package:flutter/foundation.dart";
 import "package:flutter/foundation.dart";
 import 'package:logging/logging.dart';
 import 'package:logging/logging.dart';
+import 'package:path/path.dart' as file_path;
+import "package:photo_manager/photo_manager.dart";
 import 'package:photos/core/configuration.dart';
 import 'package:photos/core/configuration.dart';
+import "package:photos/core/event_bus.dart";
 import 'package:photos/core/network/network.dart';
 import 'package:photos/core/network/network.dart';
+import "package:photos/db/files_db.dart";
+import "package:photos/events/local_photos_updated_event.dart";
 import 'package:photos/models/file/file.dart';
 import 'package:photos/models/file/file.dart';
 import "package:photos/models/file/file_type.dart";
 import "package:photos/models/file/file_type.dart";
+import "package:photos/models/ignored_file.dart";
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/collections_service.dart';
+import "package:photos/services/ignored_files_service.dart";
+import "package:photos/services/local_sync_service.dart";
 import 'package:photos/utils/crypto_util.dart';
 import 'package:photos/utils/crypto_util.dart';
 import "package:photos/utils/data_util.dart";
 import "package:photos/utils/data_util.dart";
 import "package:photos/utils/fake_progress.dart";
 import "package:photos/utils/fake_progress.dart";
+import "package:photos/utils/file_util.dart";
 
 
 final _logger = Logger("file_download_util");
 final _logger = Logger("file_download_util");
 
 
@@ -115,6 +124,97 @@ Future<Uint8List> getFileKeyUsingBgWorker(EnteFile file) async {
   );
   );
 }
 }
 
 
+Future<void> downloadToGallery(EnteFile file) async {
+  try {
+    final FileType type = file.fileType;
+    final bool downloadLivePhotoOnDroid =
+        type == FileType.livePhoto && Platform.isAndroid;
+    AssetEntity? savedAsset;
+    final File? fileToSave = await getFile(file);
+    //Disabling notifications for assets changing to insert the file into
+    //files db before triggering a sync.
+    await PhotoManager.stopChangeNotify();
+    if (type == FileType.image) {
+      savedAsset = await PhotoManager.editor
+          .saveImageWithPath(fileToSave!.path, title: file.title!);
+    } else if (type == FileType.video) {
+      savedAsset =
+          await PhotoManager.editor.saveVideo(fileToSave!, title: file.title!);
+    } else if (type == FileType.livePhoto) {
+      final File? liveVideoFile =
+          await getFileFromServer(file, liveVideo: true);
+      if (liveVideoFile == null) {
+        throw AssertionError("Live video can not be null");
+      }
+      if (downloadLivePhotoOnDroid) {
+        await _saveLivePhotoOnDroid(fileToSave!, liveVideoFile, file);
+      } else {
+        savedAsset = await PhotoManager.editor.darwin.saveLivePhoto(
+          imageFile: fileToSave!,
+          videoFile: liveVideoFile,
+          title: file.title!,
+        );
+      }
+    }
+
+    if (savedAsset != null) {
+      file.localID = savedAsset.id;
+      await FilesDB.instance.insert(file);
+      Bus.instance.fire(
+        LocalPhotosUpdatedEvent(
+          [file],
+          source: "download",
+        ),
+      );
+    } else if (!downloadLivePhotoOnDroid && savedAsset == null) {
+      _logger.severe('Failed to save assert of type $type');
+    }
+  } catch (e) {
+    _logger.severe("Failed to save file", e);
+    rethrow;
+  } finally {
+    await PhotoManager.startChangeNotify();
+    LocalSyncService.instance.checkAndSync().ignore();
+  }
+}
+
+Future<void> _saveLivePhotoOnDroid(
+  File image,
+  File video,
+  EnteFile enteFile,
+) async {
+  debugPrint("Downloading LivePhoto on Droid");
+  AssetEntity? savedAsset = await (PhotoManager.editor
+      .saveImageWithPath(image.path, title: enteFile.title!));
+  if (savedAsset == null) {
+    throw Exception("Failed to save image of live photo");
+  }
+  IgnoredFile ignoreVideoFile = IgnoredFile(
+    savedAsset.id,
+    savedAsset.title ?? '',
+    savedAsset.relativePath ?? 'remoteDownload',
+    "remoteDownload",
+  );
+  await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
+  final videoTitle = file_path.basenameWithoutExtension(enteFile.title!) +
+      file_path.extension(video.path);
+  savedAsset = (await (PhotoManager.editor.saveVideo(
+    video,
+    title: videoTitle,
+  )));
+  if (savedAsset == null) {
+    throw Exception("Failed to save video of live photo");
+  }
+
+  ignoreVideoFile = IgnoredFile(
+    savedAsset.id,
+    savedAsset.title ?? videoTitle,
+    savedAsset.relativePath ?? 'remoteDownload',
+    "remoteDownload",
+  );
+  await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
+}
+
 Uint8List _decryptFileKey(Map<String, dynamic> args) {
 Uint8List _decryptFileKey(Map<String, dynamic> args) {
   final encryptedKey = CryptoUtil.base642bin(args["encryptedKey"]);
   final encryptedKey = CryptoUtil.base642bin(args["encryptedKey"]);
   final nonce = CryptoUtil.base642bin(args["keyDecryptionNonce"]);
   final nonce = CryptoUtil.base642bin(args["keyDecryptionNonce"]);

+ 16 - 4
mobile/lib/utils/file_uploader.dart

@@ -357,10 +357,16 @@ class FileUploader {
     final List<ConnectivityResult> connections =
     final List<ConnectivityResult> connections =
         await (Connectivity().checkConnectivity());
         await (Connectivity().checkConnectivity());
     bool canUploadUnderCurrentNetworkConditions = true;
     bool canUploadUnderCurrentNetworkConditions = true;
-    if (connections.any((element) => element == ConnectivityResult.mobile)) {
-      canUploadUnderCurrentNetworkConditions =
-          Configuration.instance.shouldBackupOverMobileData();
+    if (!Configuration.instance.shouldBackupOverMobileData()) {
+      if (connections.any((element) => element == ConnectivityResult.mobile)) {
+        canUploadUnderCurrentNetworkConditions = false;
+      } else {
+        _logger.info(
+          "mobileBackupDisabled, backing up with connections: ${connections.map((e) => e.name).toString()}",
+        );
+      }
     }
     }
+
     if (!canUploadUnderCurrentNetworkConditions) {
     if (!canUploadUnderCurrentNetworkConditions) {
       throw WiFiUnavailableError();
       throw WiFiUnavailableError();
     }
     }
@@ -370,7 +376,13 @@ class FileUploader {
     if (Platform.isAndroid) {
     if (Platform.isAndroid) {
       final bool hasPermission = await Permission.accessMediaLocation.isGranted;
       final bool hasPermission = await Permission.accessMediaLocation.isGranted;
       if (!hasPermission) {
       if (!hasPermission) {
-        throw NoMediaLocationAccessError();
+        final permissionStatus = await Permission.accessMediaLocation.request();
+        if (!permissionStatus.isGranted) {
+          _logger.severe(
+            "Media location access denied with permission status: ${permissionStatus.name}",
+          );
+          throw NoMediaLocationAccessError();
+        }
       }
       }
     }
     }
   }
   }

+ 10 - 0
mobile/plugins/ente_cast/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
+  channel: stable
+
+project_type: plugin

+ 1 - 0
mobile/plugins/ente_cast/analysis_options.yaml

@@ -0,0 +1 @@
+include: ../../analysis_options.yaml

+ 2 - 0
mobile/plugins/ente_cast/lib/ente_cast.dart

@@ -0,0 +1,2 @@
+export 'src/model.dart';
+export 'src/service.dart';

+ 5 - 0
mobile/plugins/ente_cast/lib/src/model.dart

@@ -0,0 +1,5 @@
+// create enum for type of message for cast
+enum CastMessageType {
+  pairCode,
+  alreadyCasting,
+}

+ 18 - 0
mobile/plugins/ente_cast/lib/src/service.dart

@@ -0,0 +1,18 @@
+import "package:ente_cast/src/model.dart";
+import "package:flutter/widgets.dart";
+
+abstract class CastService {
+  bool get isSupported;
+  Future<List<(String, Object)>> searchDevices();
+  Future<void> connectDevice(
+    BuildContext context,
+    Object device, {
+    int? collectionID,
+    // callback that take a map of string, dynamic
+    void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
+  });
+  // returns a map of sessionID to deviceNames
+  Map<String, String> getActiveSessions();
+
+  Future<void> closeActiveCasts();
+}

+ 19 - 0
mobile/plugins/ente_cast/pubspec.yaml

@@ -0,0 +1,19 @@
+name: ente_cast
+version: 0.0.1
+publish_to: none
+
+environment:
+  sdk: '>=3.3.0 <4.0.0'
+
+dependencies:
+  collection:
+  dio: ^4.0.6
+  flutter:
+    sdk: flutter
+  shared_preferences: ^2.0.5
+  stack_trace:
+
+dev_dependencies:
+  flutter_lints:
+
+flutter:

+ 10 - 0
mobile/plugins/ente_cast_none/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
+  channel: stable
+
+project_type: plugin

+ 1 - 0
mobile/plugins/ente_cast_none/analysis_options.yaml

@@ -0,0 +1 @@
+include: ../../analysis_options.yaml

+ 1 - 0
mobile/plugins/ente_cast_none/lib/ente_cast_none.dart

@@ -0,0 +1 @@
+export 'src/service.dart';

+ 35 - 0
mobile/plugins/ente_cast_none/lib/src/service.dart

@@ -0,0 +1,35 @@
+import "package:ente_cast/ente_cast.dart";
+import "package:flutter/widgets.dart";
+
+class CastServiceImpl extends CastService {
+  @override
+  Future<void> connectDevice(
+    BuildContext context,
+    Object device, {
+    int? collectionID,
+    void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
+  }) {
+    throw UnimplementedError();
+  }
+
+  @override
+  bool get isSupported => false;
+
+  @override
+  Future<List<(String, Object)>> searchDevices() {
+    // TODO: implement searchDevices
+    throw UnimplementedError();
+  }
+
+  @override
+  Future<void> closeActiveCasts() {
+    // TODO: implement closeActiveCasts
+    throw UnimplementedError();
+  }
+
+  @override
+  Map<String, String> getActiveSessions() {
+    // TODO: implement getActiveSessions
+    throw UnimplementedError();
+  }
+}

+ 18 - 0
mobile/plugins/ente_cast_none/pubspec.yaml

@@ -0,0 +1,18 @@
+name: ente_cast_none
+version: 0.0.1
+publish_to: none
+
+environment:
+  sdk: '>=3.3.0 <4.0.0'
+
+dependencies:
+  ente_cast:
+    path: ../ente_cast
+  flutter:
+    sdk: flutter
+  stack_trace:
+
+dev_dependencies:
+  flutter_lints:
+
+flutter:

+ 10 - 0
mobile/plugins/ente_cast_normal/.metadata

@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
+  channel: stable
+
+project_type: plugin

+ 1 - 0
mobile/plugins/ente_cast_normal/analysis_options.yaml

@@ -0,0 +1 @@
+include: ../../analysis_options.yaml

+ 1 - 0
mobile/plugins/ente_cast_normal/lib/ente_cast_normal.dart

@@ -0,0 +1 @@
+export 'src/service.dart';

+ 100 - 0
mobile/plugins/ente_cast_normal/lib/src/service.dart

@@ -0,0 +1,100 @@
+import "dart:developer" as dev;
+
+import "package:cast/cast.dart";
+import "package:ente_cast/ente_cast.dart";
+import "package:flutter/material.dart";
+
+class CastServiceImpl extends CastService {
+  final String _appId = 'F5BCEC64';
+  final String _pairRequestNamespace = 'urn:x-cast:pair-request';
+  final Map<int, String> collectionIDToSessions = {};
+
+  @override
+  Future<void> connectDevice(
+    BuildContext context,
+    Object device, {
+    int? collectionID,
+    void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
+  }) async {
+    final CastDevice castDevice = device as CastDevice;
+    final session = await CastSessionManager().startSession(castDevice);
+    session.messageStream.listen((message) {
+      if (message['type'] == "RECEIVER_STATUS") {
+        dev.log(
+          "got RECEIVER_STATUS, Send request to pair",
+          name: "CastServiceImpl",
+        );
+        session.sendMessage(_pairRequestNamespace, {});
+      } else {
+        if (onMessage != null && message.containsKey("code")) {
+          onMessage(
+            {
+              CastMessageType.pairCode: message,
+            },
+          );
+        }
+        print('receive message: $message');
+      }
+    });
+
+    session.stateStream.listen((state) {
+      if (state == CastSessionState.connected) {
+        debugPrint("Send request to pair");
+        session.sendMessage(_pairRequestNamespace, {});
+      } else if (state == CastSessionState.closed) {
+        dev.log('Session closed', name: 'CastServiceImpl');
+      }
+    });
+
+    debugPrint("Send request to launch");
+    session.sendMessage(CastSession.kNamespaceReceiver, {
+      'type': 'LAUNCH',
+      'appId': _appId, // set the appId of your app here
+    });
+    // session.sendMessage('urn:x-cast:pair-request', {});
+  }
+
+  @override
+  Future<List<(String, Object)>> searchDevices() {
+    return CastDiscoveryService().search().then((devices) {
+      return devices.map((device) => (device.name, device)).toList();
+    });
+  }
+
+  @override
+  bool get isSupported => true;
+
+  @override
+  Future<void> closeActiveCasts() {
+    final sessions = CastSessionManager().sessions;
+    for (final session in sessions) {
+      debugPrint("send close message for ${session.sessionId}");
+      Future(() {
+        session.sendMessage(CastSession.kNamespaceConnection, {
+          'type': 'CLOSE',
+        });
+      }).timeout(
+        const Duration(seconds: 5),
+        onTimeout: () {
+          debugPrint('sendMessage timed out after 5 seconds');
+        },
+      );
+      debugPrint("close session ${session.sessionId}");
+      session.close();
+    }
+    CastSessionManager().sessions.clear();
+    return Future.value();
+  }
+
+  @override
+  Map<String, String> getActiveSessions() {
+    final sessions = CastSessionManager().sessions;
+    final Map<String, String> result = {};
+    for (final session in sessions) {
+      if (session.state == CastSessionState.connected) {
+        result[session.sessionId] = session.state.toString();
+      }
+    }
+    return result;
+  }
+}

+ 333 - 0
mobile/plugins/ente_cast_normal/pubspec.lock

@@ -0,0 +1,333 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  async:
+    dependency: transitive
+    description:
+      name: async
+      sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.11.0"
+  cast:
+    dependency: "direct main"
+    description:
+      path: "."
+      ref: multicast_version
+      resolved-ref: "1f39cd4d6efa9363e77b2439f0317bae0c92dda1"
+      url: "https://github.com/guyluz11/flutter_cast.git"
+    source: git
+    version: "2.0.9"
+  characters:
+    dependency: transitive
+    description:
+      name: characters
+      sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.0"
+  collection:
+    dependency: transitive
+    description:
+      name: collection
+      sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.18.0"
+  dio:
+    dependency: transitive
+    description:
+      name: dio
+      sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.6"
+  ente_cast:
+    dependency: "direct main"
+    description:
+      path: "../ente_cast"
+      relative: true
+    source: path
+    version: "0.0.1"
+  ffi:
+    dependency: transitive
+    description:
+      name: ffi
+      sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  file:
+    dependency: transitive
+    description:
+      name: file
+      sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.0.0"
+  fixnum:
+    dependency: transitive
+    description:
+      name: fixnum
+      sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  flutter:
+    dependency: "direct main"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_lints:
+    dependency: "direct dev"
+    description:
+      name: flutter_lints
+      sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.2"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  http:
+    dependency: transitive
+    description:
+      name: http
+      sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.2"
+  lints:
+    dependency: transitive
+    description:
+      name: lints
+      sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.0"
+  material_color_utilities:
+    dependency: transitive
+    description:
+      name: material_color_utilities
+      sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.8.0"
+  meta:
+    dependency: transitive
+    description:
+      name: meta
+      sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.11.0"
+  multicast_dns:
+    dependency: transitive
+    description:
+      name: multicast_dns
+      sha256: "316cc47a958d4bd3c67bd238fe8b44fdfb6133bad89cb191c0c3bd3edb14e296"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.3.2+6"
+  path:
+    dependency: transitive
+    description:
+      name: path
+      sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.9.0"
+  path_provider_linux:
+    dependency: transitive
+    description:
+      name: path_provider_linux
+      sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.1"
+  path_provider_platform_interface:
+    dependency: transitive
+    description:
+      name: path_provider_platform_interface
+      sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  path_provider_windows:
+    dependency: transitive
+    description:
+      name: path_provider_windows
+      sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.1"
+  platform:
+    dependency: transitive
+    description:
+      name: platform
+      sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.4"
+  plugin_platform_interface:
+    dependency: transitive
+    description:
+      name: plugin_platform_interface
+      sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.8"
+  protobuf:
+    dependency: transitive
+    description:
+      name: protobuf
+      sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.0"
+  shared_preferences:
+    dependency: transitive
+    description:
+      name: shared_preferences
+      sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.3"
+  shared_preferences_android:
+    dependency: transitive
+    description:
+      name: shared_preferences_android
+      sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.2"
+  shared_preferences_foundation:
+    dependency: transitive
+    description:
+      name: shared_preferences_foundation
+      sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.5"
+  shared_preferences_linux:
+    dependency: transitive
+    description:
+      name: shared_preferences_linux
+      sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  shared_preferences_platform_interface:
+    dependency: transitive
+    description:
+      name: shared_preferences_platform_interface
+      sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  shared_preferences_web:
+    dependency: transitive
+    description:
+      name: shared_preferences_web
+      sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.0"
+  shared_preferences_windows:
+    dependency: transitive
+    description:
+      name: shared_preferences_windows
+      sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  sky_engine:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.99"
+  source_span:
+    dependency: transitive
+    description:
+      name: source_span
+      sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.10.0"
+  stack_trace:
+    dependency: "direct main"
+    description:
+      name: stack_trace
+      sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.11.1"
+  string_scanner:
+    dependency: transitive
+    description:
+      name: string_scanner
+      sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.0"
+  term_glyph:
+    dependency: transitive
+    description:
+      name: term_glyph
+      sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.2"
+  vector_math:
+    dependency: transitive
+    description:
+      name: vector_math
+      sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.4"
+  web:
+    dependency: transitive
+    description:
+      name: web
+      sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.5.1"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "5.4.0"
+  xdg_directories:
+    dependency: transitive
+    description:
+      name: xdg_directories
+      sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.4"
+sdks:
+  dart: ">=3.3.0 <4.0.0"
+  flutter: ">=3.19.0"

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác