Przeglądaj źródła

Merge branch 'main' into mobile-resumable

Neeraj Gupta 1 rok temu
rodzic
commit
12b4ab122e
100 zmienionych plików z 1855 dodań i 1884 usunięć
  1. 1 1
      .github/workflows/auth-crowdin.yml
  2. 3 3
      .github/workflows/auth-release.yml
  3. 30 0
      .github/workflows/desktop-lint.yml
  4. 1 1
      .github/workflows/mobile-crowdin.yml
  5. 57 0
      .github/workflows/mobile-internal-release.yml
  6. 6 1
      .github/workflows/mobile-release.yml
  7. 39 0
      .github/workflows/web-crowdin-update.yml
  8. 1 1
      .github/workflows/web-crowdin.yml
  9. 1 1
      .github/workflows/web-deploy-accounts.yml
  10. 1 1
      .github/workflows/web-deploy-cast.yml
  11. 0 1
      auth/lib/l10n/arb/app_ar.arb
  12. 8 1
      auth/lib/l10n/arb/app_de.arb
  13. 0 1
      auth/lib/l10n/arb/app_en.arb
  14. 9 2
      auth/lib/l10n/arb/app_es.arb
  15. 0 1
      auth/lib/l10n/arb/app_fa.arb
  16. 0 1
      auth/lib/l10n/arb/app_fi.arb
  17. 0 1
      auth/lib/l10n/arb/app_fr.arb
  18. 0 1
      auth/lib/l10n/arb/app_he.arb
  19. 0 1
      auth/lib/l10n/arb/app_it.arb
  20. 0 1
      auth/lib/l10n/arb/app_ja.arb
  21. 0 1
      auth/lib/l10n/arb/app_ka.arb
  22. 0 1
      auth/lib/l10n/arb/app_nl.arb
  23. 0 1
      auth/lib/l10n/arb/app_pl.arb
  24. 0 1
      auth/lib/l10n/arb/app_pt.arb
  25. 0 1
      auth/lib/l10n/arb/app_ru.arb
  26. 0 1
      auth/lib/l10n/arb/app_sv.arb
  27. 0 1
      auth/lib/l10n/arb/app_ti.arb
  28. 0 1
      auth/lib/l10n/arb/app_tr.arb
  29. 0 1
      auth/lib/l10n/arb/app_vi.arb
  30. 0 1
      auth/lib/l10n/arb/app_zh.arb
  31. 1 0
      auth/lib/main.dart
  32. 22 7
      auth/lib/models/code.dart
  33. 10 3
      auth/lib/onboarding/view/setup_enter_secret_key_page.dart
  34. 5 5
      auth/lib/ui/code_widget.dart
  35. 2 0
      auth/lib/ui/settings/data/import/bitwarden_import.dart
  36. 2 2
      auth/lib/utils/totp_util.dart
  37. 1 1
      auth/linux/packaging/rpm/make_config.yaml
  38. 3 3
      auth/pubspec.lock
  39. 3 2
      auth/pubspec.yaml
  40. 8 1
      cli/README.md
  41. 2 1
      cli/cmd/account.go
  42. 1 1
      cli/docs/generated/ente.md
  43. 2 2
      cli/docs/generated/ente_account.md
  44. 6 2
      cli/docs/generated/ente_account_add.md
  45. 1 1
      cli/docs/generated/ente_account_get-token.md
  46. 1 1
      cli/docs/generated/ente_account_list.md
  47. 1 1
      cli/docs/generated/ente_account_update.md
  48. 1 1
      cli/docs/generated/ente_admin.md
  49. 1 1
      cli/docs/generated/ente_admin_delete-user.md
  50. 1 1
      cli/docs/generated/ente_admin_disable-2fa.md
  51. 1 1
      cli/docs/generated/ente_admin_get-user-id.md
  52. 1 1
      cli/docs/generated/ente_admin_list-users.md
  53. 1 1
      cli/docs/generated/ente_admin_update-subscription.md
  54. 1 1
      cli/docs/generated/ente_auth.md
  55. 1 1
      cli/docs/generated/ente_auth_decrypt.md
  56. 1 1
      cli/docs/generated/ente_export.md
  57. 1 1
      cli/docs/generated/ente_version.md
  58. 1 1
      cli/pkg/account.go
  59. 19 9
      desktop/.eslintrc.js
  60. 0 55
      desktop/.github/workflows/build.yml
  61. 70 0
      desktop/.github/workflows/desktop-draft-release.yml
  62. 83 0
      desktop/.github/workflows/desktop-release.yml
  63. 8 0
      desktop/CHANGELOG.md
  64. 0 6
      desktop/README.md
  65. 6 3
      desktop/docs/dependencies.md
  66. 36 52
      desktop/docs/release.md
  67. 10 5
      desktop/package.json
  68. 144 85
      desktop/src/main.ts
  69. 0 54
      desktop/src/main/dialogs.ts
  70. 0 31
      desktop/src/main/fs.ts
  71. 0 63
      desktop/src/main/init.ts
  72. 115 95
      desktop/src/main/ipc.ts
  73. 6 6
      desktop/src/main/log.ts
  74. 13 11
      desktop/src/main/menu.ts
  75. 0 19
      desktop/src/main/platform.ts
  76. 25 13
      desktop/src/main/services/app-update.ts
  77. 50 0
      desktop/src/main/services/auto-launcher.ts
  78. 0 41
      desktop/src/main/services/autoLauncher.ts
  79. 0 39
      desktop/src/main/services/autoLauncherClients/linuxAndWinAutoLauncher.ts
  80. 0 28
      desktop/src/main/services/autoLauncherClients/macAutoLauncher.ts
  81. 0 45
      desktop/src/main/services/chokidar.ts
  82. 89 0
      desktop/src/main/services/dir.ts
  83. 68 115
      desktop/src/main/services/ffmpeg.ts
  84. 19 179
      desktop/src/main/services/fs.ts
  85. 159 0
      desktop/src/main/services/image.ts
  86. 0 294
      desktop/src/main/services/imageProcessor.ts
  87. 75 151
      desktop/src/main/services/ml-clip.ts
  88. 17 73
      desktop/src/main/services/ml-face.ts
  89. 56 10
      desktop/src/main/services/ml.ts
  90. 22 10
      desktop/src/main/services/store.ts
  91. 130 88
      desktop/src/main/services/upload.ts
  92. 132 77
      desktop/src/main/services/watch.ts
  93. 0 18
      desktop/src/main/stores/keys.store.ts
  94. 5 2
      desktop/src/main/stores/safe-storage.ts
  95. 54 0
      desktop/src/main/stores/upload-status.ts
  96. 0 25
      desktop/src/main/stores/upload.store.ts
  97. 3 3
      desktop/src/main/stores/user-preferences.ts
  98. 0 47
      desktop/src/main/stores/watch.store.ts
  99. 77 0
      desktop/src/main/stores/watch.ts
  100. 125 58
      desktop/src/main/stream.ts

+ 1 - 1
.github/workflows/auth-crowdin.yml

@@ -30,7 +30,7 @@ jobs:
                   upload_sources: true
                   upload_sources: true
                   upload_translations: false
                   upload_translations: false
                   download_translations: true
                   download_translations: true
-                  localization_branch_name: crowdin-translations-auth
+                  localization_branch_name: translations/auth
                   create_pull_request: true
                   create_pull_request: true
                   skip_untranslated_strings: true
                   skip_untranslated_strings: true
                   pull_request_title: "[auth] New translations"
                   pull_request_title: "[auth] New translations"

+ 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 - 1
.github/workflows/mobile-crowdin.yml

@@ -30,7 +30,7 @@ jobs:
                   upload_sources: true
                   upload_sources: true
                   upload_translations: false
                   upload_translations: false
                   download_translations: true
                   download_translations: true
-                  localization_branch_name: crowdin-translations-mobile
+                  localization_branch_name: translations/mobile
                   create_pull_request: true
                   create_pull_request: true
                   skip_untranslated_strings: true
                   skip_untranslated_strings: true
                   pull_request_title: "[mobile] New translations"
                   pull_request_title: "[mobile] New translations"

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

@@ -0,0 +1,57 @@
+name: "Internal Release - Photos"
+
+on:
+    workflow_dispatch: # Allow manually running the action
+
+env:
+    FLUTTER_VERSION: "3.19.3"
+
+jobs:
+    build:
+        runs-on: ubuntu-latest
+
+        defaults:
+            run:
+                working-directory: mobile
+
+        steps:
+            - name: Checkout code and submodules
+              uses: actions/checkout@v4
+              with:
+                  submodules: recursive
+
+            - name: Setup JDK 17
+              uses: actions/setup-java@v1
+              with:
+                  java-version: 17
+
+            - name: Install Flutter ${{ env.FLUTTER_VERSION  }}
+              uses: subosito/flutter-action@v2
+              with:
+                  channel: "stable"
+                  flutter-version: ${{ env.FLUTTER_VERSION  }}
+                  cache: true
+
+            - name: Setup keys
+              uses: timheuer/base64-to-file@v1
+              with:
+                  fileName: "keystore/ente_photos_key.jks"
+                  encodedString: ${{ secrets.SIGNING_KEY_PHOTOS }}
+
+            - name: Build PlayStore AAB
+              run: |
+                  flutter build appbundle --release --flavor playstore
+              env:
+                  SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_photos_key.jks"
+                  SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS_PHOTOS }}
+                  SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD_PHOTOS }}
+                  SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD_PHOTOS }}
+
+            - name: Upload AAB to PlayStore
+              uses: r0adkll/upload-google-play@v1
+              with:
+                  serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
+                  packageName: io.ente.photos
+                  releaseFiles: mobile/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab
+                  track: internal
+                  changesNotSentForReview: true

+ 6 - 1
.github/workflows/mobile-release.yml

@@ -9,7 +9,7 @@ on:
             - "photos-v*"
             - "photos-v*"
 
 
 env:
 env:
-    FLUTTER_VERSION: "3.19.5"
+    FLUTTER_VERSION: "3.19.3"
 
 
 jobs:
 jobs:
     build:
     build:
@@ -25,6 +25,11 @@ jobs:
               with:
               with:
                   submodules: recursive
                   submodules: recursive
 
 
+            - name: Setup JDK 17
+              uses: actions/setup-java@v1
+              with:
+                  java-version: 17
+
             - name: Install Flutter ${{ env.FLUTTER_VERSION  }}
             - name: Install Flutter ${{ env.FLUTTER_VERSION  }}
               uses: subosito/flutter-action@v2
               uses: subosito/flutter-action@v2
               with:
               with:

+ 39 - 0
.github/workflows/web-crowdin-update.yml

@@ -0,0 +1,39 @@
+name: "Update Crowdin translations (web)"
+
+# This is a variant of web-crowdin.yml that also uploads the translated strings
+# (in addition to the source strings). This allows us to change the strings in
+# our source code for an automated refactoring (e.g. renaming a key), and then
+# run this workflow to update the data in Crowdin taking our source code as the
+# source of truth.
+
+on:
+    # Only allow running manually.
+    workflow_dispatch:
+
+jobs:
+    synchronize-with-crowdin:
+        runs-on: ubuntu-latest
+
+        steps:
+            - name: Checkout
+              uses: actions/checkout@v4
+
+            - name: Crowdin's action
+              uses: crowdin/github-action@v1
+              with:
+                  base_path: "web/"
+                  config: "web/crowdin.yml"
+                  upload_sources: true
+                  # This is what differs from web-crowdin.yml
+                  upload_translations: true
+                  download_translations: true
+                  localization_branch_name: translations/web
+                  create_pull_request: true
+                  skip_untranslated_strings: true
+                  pull_request_title: "[web] Updated translations"
+                  pull_request_body: "Updated translations from [Crowdin](https://crowdin.com/project/ente-photos-web)"
+                  pull_request_base_branch_name: "main"
+                  project_id: 569613
+              env:
+                  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+                  CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

+ 1 - 1
.github/workflows/web-crowdin.yml

@@ -36,7 +36,7 @@ jobs:
                   upload_sources: true
                   upload_sources: true
                   upload_translations: false
                   upload_translations: false
                   download_translations: true
                   download_translations: true
-                  localization_branch_name: crowdin-translations-web
+                  localization_branch_name: translations/web
                   create_pull_request: true
                   create_pull_request: true
                   skip_untranslated_strings: true
                   skip_untranslated_strings: true
                   pull_request_title: "[web] New translations"
                   pull_request_title: "[web] New translations"

+ 1 - 1
.github/workflows/web-deploy-accounts.yml

@@ -3,7 +3,7 @@ name: "Deploy (accounts)"
 on:
 on:
     push:
     push:
         # Run workflow on pushes to the deploy/accounts
         # Run workflow on pushes to the deploy/accounts
-        branches: [deploy/accounts]
+        branches: [deploy/accounts, deploy-f/accounts]
 
 
 jobs:
 jobs:
     deploy:
     deploy:

+ 1 - 1
.github/workflows/web-deploy-cast.yml

@@ -3,7 +3,7 @@ name: "Deploy (cast)"
 on:
 on:
     push:
     push:
         # Run workflow on pushes to the deploy/cast
         # Run workflow on pushes to the deploy/cast
-        branches: [deploy/cast]
+        branches: [deploy/cast, deploy-f/cast]
 
 
 jobs:
 jobs:
     deploy:
     deploy:

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "المصدِّر",
   "codeIssuerHint": "المصدِّر",
   "codeSecretKeyHint": "الرمز السري",
   "codeSecretKeyHint": "الرمز السري",
   "codeAccountHint": "الحساب (you@domain.com)",
   "codeAccountHint": "الحساب (you@domain.com)",
-  "accountKeyType": "نوع المفتاح",
   "sessionExpired": "انتهت صلاحية الجلسة",
   "sessionExpired": "انتهت صلاحية الجلسة",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

+ 8 - 1
auth/lib/l10n/arb/app_de.arb

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Aussteller",
   "codeIssuerHint": "Aussteller",
   "codeSecretKeyHint": "Geheimer Schlüssel",
   "codeSecretKeyHint": "Geheimer Schlüssel",
   "codeAccountHint": "Konto (you@domain.com)",
   "codeAccountHint": "Konto (you@domain.com)",
-  "accountKeyType": "Art des Schlüssels",
   "sessionExpired": "Sitzung abgelaufen",
   "sessionExpired": "Sitzung abgelaufen",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"
@@ -78,12 +77,14 @@
   "data": "Datei",
   "data": "Datei",
   "importCodes": "Codes importieren",
   "importCodes": "Codes importieren",
   "importTypePlainText": "Klartext",
   "importTypePlainText": "Klartext",
+  "importTypeEnteEncrypted": "Verschlüsselter Ente-Export",
   "passwordForDecryptingExport": "Passwort um den Export zu entschlüsseln",
   "passwordForDecryptingExport": "Passwort um den Export zu entschlüsseln",
   "passwordEmptyError": "Passwort kann nicht leer sein",
   "passwordEmptyError": "Passwort kann nicht leer sein",
   "importFromApp": "Importiere Codes von {appName}",
   "importFromApp": "Importiere Codes von {appName}",
   "importGoogleAuthGuide": "Exportiere deine Accounts von Google Authenticator zu einem QR-Code, durch die \"Konten übertragen\" Option. Scanne den QR-Code danach mit einem anderen Gerät.\n\nTipp: Du kannst die Kamera eines Laptops verwenden, um ein Foto den dem QR-Code zu erstellen.",
   "importGoogleAuthGuide": "Exportiere deine Accounts von Google Authenticator zu einem QR-Code, durch die \"Konten übertragen\" Option. Scanne den QR-Code danach mit einem anderen Gerät.\n\nTipp: Du kannst die Kamera eines Laptops verwenden, um ein Foto den dem QR-Code zu erstellen.",
   "importSelectJsonFile": "Wähle eine JSON-Datei",
   "importSelectJsonFile": "Wähle eine JSON-Datei",
   "importSelectAppExport": "{appName} Exportdatei auswählen",
   "importSelectAppExport": "{appName} Exportdatei auswählen",
+  "importEnteEncGuide": "Wähle die von Ente exportierte, verschlüsselte JSON-Datei",
   "importRaivoGuide": "Verwenden Sie die Option \"Export OTPs to Zip archive\" in den Raivo-Einstellungen.\n\nEntpacken Sie die Zip-Datei und importieren Sie die JSON-Datei.",
   "importRaivoGuide": "Verwenden Sie die Option \"Export OTPs to Zip archive\" in den Raivo-Einstellungen.\n\nEntpacken Sie die Zip-Datei und importieren Sie die JSON-Datei.",
   "importBitwardenGuide": "Verwenden Sie die Option \"Tresor exportieren\" innerhalb der Bitwarden Tools und importieren Sie die unverschlüsselte JSON-Datei.",
   "importBitwardenGuide": "Verwenden Sie die Option \"Tresor exportieren\" innerhalb der Bitwarden Tools und importieren Sie die unverschlüsselte JSON-Datei.",
   "importAegisGuide": "Verwenden Sie die Option \"Tresor exportieren\" in den Aegis-Einstellungen.\n\nFalls Ihr Tresor verschlüsselt ist, müssen Sie das Passwort für den Tresor eingeben, um ihn zu entschlüsseln.",
   "importAegisGuide": "Verwenden Sie die Option \"Tresor exportieren\" in den Aegis-Einstellungen.\n\nFalls Ihr Tresor verschlüsselt ist, müssen Sie das Passwort für den Tresor eingeben, um ihn zu entschlüsseln.",
@@ -121,12 +122,14 @@
   "suggestFeatures": "Features vorschlagen",
   "suggestFeatures": "Features vorschlagen",
   "faq": "FAQ",
   "faq": "FAQ",
   "faq_q_1": "Wie sicher ist Auth?",
   "faq_q_1": "Wie sicher ist Auth?",
+  "faq_a_1": "Alle Codes, die du über Auth sicherst, werden Ende-zu-Ende-verschlüsselt gespeichert. Das bedeutet, dass nur du auf deine Codes zugreifen kannst. Unsere Anwendungen sind quelloffen und unsere Kryptografie wurde extern geprüft.",
   "faq_q_2": "Kann ich auf meine Codes auf dem Desktop zugreifen?",
   "faq_q_2": "Kann ich auf meine Codes auf dem Desktop zugreifen?",
   "faq_a_2": "Sie können auf Ihre Codes im Web via auth.ente.io zugreifen.",
   "faq_a_2": "Sie können auf Ihre Codes im Web via auth.ente.io zugreifen.",
   "faq_q_3": "Wie kann ich Codes löschen?",
   "faq_q_3": "Wie kann ich Codes löschen?",
   "faq_a_3": "Sie können einen Code löschen, indem Sie auf dem Code nach links wischen.",
   "faq_a_3": "Sie können einen Code löschen, indem Sie auf dem Code nach links wischen.",
   "faq_q_4": "Wie kann ich das Projekt unterstützen?",
   "faq_q_4": "Wie kann ich das Projekt unterstützen?",
   "faq_a_4": "Sie können die Entwicklung dieses Projekts unterstützen, indem Sie unsere Fotos-App auf ente.io abonnieren.",
   "faq_a_4": "Sie können die Entwicklung dieses Projekts unterstützen, indem Sie unsere Fotos-App auf ente.io abonnieren.",
+  "faq_q_5": "Wie kann ich die FaceID-Sperre in Auth aktivieren",
   "faq_a_5": "Sie können FaceID unter Einstellungen → Sicherheit → Sperrbildschirm aktivieren.",
   "faq_a_5": "Sie können FaceID unter Einstellungen → Sicherheit → Sperrbildschirm aktivieren.",
   "somethingWentWrongMessage": "Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut",
   "somethingWentWrongMessage": "Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut",
   "leaveFamily": "Familie verlassen",
   "leaveFamily": "Familie verlassen",
@@ -196,6 +199,9 @@
   "doThisLater": "Auf später verschieben",
   "doThisLater": "Auf später verschieben",
   "saveKey": "Schlüssel speichern",
   "saveKey": "Schlüssel speichern",
   "save": "Speichern",
   "save": "Speichern",
+  "send": "Senden",
+  "saveOrSendDescription": "Möchtest du dies in deinem Speicher (standardmäßig im Ordner Downloads) oder an andere Apps senden?",
+  "saveOnlyDescription": "Möchtest du dies in deinem Speicher (standardmäßig im Ordner Downloads) speichern?",
   "back": "Zurück",
   "back": "Zurück",
   "createAccount": "Account erstellen",
   "createAccount": "Account erstellen",
   "passwordStrength": "Passwortstärke: {passwordStrengthValue}",
   "passwordStrength": "Passwortstärke: {passwordStrengthValue}",
@@ -343,6 +349,7 @@
   "deleteCodeAuthMessage": "Authentifizieren, um Code zu löschen",
   "deleteCodeAuthMessage": "Authentifizieren, um Code zu löschen",
   "showQRAuthMessage": "Authentifizieren, um QR-Code anzuzeigen",
   "showQRAuthMessage": "Authentifizieren, um QR-Code anzuzeigen",
   "confirmAccountDeleteTitle": "Kontolöschung bestätigen",
   "confirmAccountDeleteTitle": "Kontolöschung bestätigen",
+  "confirmAccountDeleteMessage": "Dieses Konto ist mit anderen Ente-Apps verknüpft, falls du welche verwendest.\n\nDeine hochgeladenen Daten werden in allen Ente-Apps zur Löschung vorgemerkt und dein Konto wird endgültig gelöscht.",
   "androidBiometricHint": "Identität bestätigen",
   "androidBiometricHint": "Identität bestätigen",
   "@androidBiometricHint": {
   "@androidBiometricHint": {
     "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
     "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Issuer",
   "codeIssuerHint": "Issuer",
   "codeSecretKeyHint": "Secret Key",
   "codeSecretKeyHint": "Secret Key",
   "codeAccountHint": "Account (you@domain.com)",
   "codeAccountHint": "Account (you@domain.com)",
-  "accountKeyType": "Type of key",
   "sessionExpired": "Session expired",
   "sessionExpired": "Session expired",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

+ 9 - 2
auth/lib/l10n/arb/app_es.arb

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Emisor",
   "codeIssuerHint": "Emisor",
   "codeSecretKeyHint": "Llave Secreta",
   "codeSecretKeyHint": "Llave Secreta",
   "codeAccountHint": "Cuenta (tu@dominio.com)",
   "codeAccountHint": "Cuenta (tu@dominio.com)",
-  "accountKeyType": "Tipo de llave",
   "sessionExpired": "La sesión ha expirado",
   "sessionExpired": "La sesión ha expirado",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"
@@ -113,6 +112,7 @@
   "copied": "Copiado",
   "copied": "Copiado",
   "pleaseTryAgain": "Por favor, inténtalo nuevamente",
   "pleaseTryAgain": "Por favor, inténtalo nuevamente",
   "existingUser": "Usuario existente",
   "existingUser": "Usuario existente",
+  "newUser": "Nuevo a Ente",
   "delete": "Borrar",
   "delete": "Borrar",
   "enterYourPasswordHint": "Ingrese su contraseña",
   "enterYourPasswordHint": "Ingrese su contraseña",
   "forgotPassword": "Olvidé mi contraseña",
   "forgotPassword": "Olvidé mi contraseña",
@@ -138,6 +138,8 @@
   "enterCodeHint": "Ingrese el código de seis dígitos de su aplicación de autenticación",
   "enterCodeHint": "Ingrese el código de seis dígitos de su aplicación de autenticación",
   "lostDeviceTitle": "¿Perdió su dispositivo?",
   "lostDeviceTitle": "¿Perdió su dispositivo?",
   "twoFactorAuthTitle": "Autenticación de dos factores",
   "twoFactorAuthTitle": "Autenticación de dos factores",
+  "passkeyAuthTitle": "Verificación de llave de acceso",
+  "verifyPasskey": "Verificar llave de acceso",
   "recoverAccount": "Recuperar cuenta",
   "recoverAccount": "Recuperar cuenta",
   "enterRecoveryKeyHint": "Introduzca su clave de recuperación",
   "enterRecoveryKeyHint": "Introduzca su clave de recuperación",
   "recover": "Recuperar",
   "recover": "Recuperar",
@@ -191,6 +193,8 @@
   "recoveryKeySaveDescription": "Nosotros no almacenamos esta clave, por favor guarde dicha clave de 24 palabras en un lugar seguro.",
   "recoveryKeySaveDescription": "Nosotros no almacenamos esta clave, por favor guarde dicha clave de 24 palabras en un lugar seguro.",
   "doThisLater": "Hacer esto más tarde",
   "doThisLater": "Hacer esto más tarde",
   "saveKey": "Guardar Clave",
   "saveKey": "Guardar Clave",
+  "save": "Guardar",
+  "send": "Enviar",
   "back": "Atrás",
   "back": "Atrás",
   "createAccount": "Crear cuenta",
   "createAccount": "Crear cuenta",
   "passwordStrength": "Fortaleza de la contraseña: {passwordStrengthValue}",
   "passwordStrength": "Fortaleza de la contraseña: {passwordStrengthValue}",
@@ -397,5 +401,8 @@
   "signOutOtherDevices": "Cerrar la sesión de otros dispositivos",
   "signOutOtherDevices": "Cerrar la sesión de otros dispositivos",
   "doNotSignOut": "No cerrar la sesión",
   "doNotSignOut": "No cerrar la sesión",
   "hearUsWhereTitle": "¿Cómo conoció Ente? (opcional)",
   "hearUsWhereTitle": "¿Cómo conoció Ente? (opcional)",
-  "hearUsExplanation": "No rastreamos las aplicaciones instaladas. ¡Nos ayudaría si nos dijera dónde nos encontró!"
+  "hearUsExplanation": "No rastreamos las aplicaciones instaladas. ¡Nos ayudaría si nos dijera dónde nos encontró!",
+  "passkey": "Llave de acceso",
+  "developerSettingsWarning": "¿Estás seguro de que quieres modificar los ajustes de desarrollador?",
+  "developerSettings": "Ajustes de desarrollador"
 }
 }

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

@@ -14,7 +14,6 @@
   "codeIssuerHint": "صادر کننده",
   "codeIssuerHint": "صادر کننده",
   "codeSecretKeyHint": "کلید مخفی",
   "codeSecretKeyHint": "کلید مخفی",
   "codeAccountHint": "حساب (you@domain.com)",
   "codeAccountHint": "حساب (you@domain.com)",
-  "accountKeyType": "نوع کلید",
   "sessionExpired": "نشست منقضی شده است",
   "sessionExpired": "نشست منقضی شده است",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -12,7 +12,6 @@
   "codeIssuerHint": "Myöntäjä",
   "codeIssuerHint": "Myöntäjä",
   "codeSecretKeyHint": "Salainen avain",
   "codeSecretKeyHint": "Salainen avain",
   "codeAccountHint": "Tili (sinun@jokinosoite.com)",
   "codeAccountHint": "Tili (sinun@jokinosoite.com)",
-  "accountKeyType": "Avaimen tyyppi",
   "sessionExpired": "Istunto on vanheutunut",
   "sessionExpired": "Istunto on vanheutunut",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Émetteur",
   "codeIssuerHint": "Émetteur",
   "codeSecretKeyHint": "Clé secrète",
   "codeSecretKeyHint": "Clé secrète",
   "codeAccountHint": "Compte (vous@exemple.com)",
   "codeAccountHint": "Compte (vous@exemple.com)",
-  "accountKeyType": "Type de clé",
   "sessionExpired": "Session expirée",
   "sessionExpired": "Session expirée",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -19,7 +19,6 @@
   "codeIssuerHint": "מנפיק",
   "codeIssuerHint": "מנפיק",
   "codeSecretKeyHint": "מפתח סודי",
   "codeSecretKeyHint": "מפתח סודי",
   "codeAccountHint": "חשבון(you@domain.com)",
   "codeAccountHint": "חשבון(you@domain.com)",
-  "accountKeyType": "סוג מפתח",
   "sessionExpired": "זמן החיבור הסתיים",
   "sessionExpired": "זמן החיבור הסתיים",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Emittente",
   "codeIssuerHint": "Emittente",
   "codeSecretKeyHint": "Codice segreto",
   "codeSecretKeyHint": "Codice segreto",
   "codeAccountHint": "Account (username@dominio.it)",
   "codeAccountHint": "Account (username@dominio.it)",
-  "accountKeyType": "Tipo di chiave",
   "sessionExpired": "Sessione scaduta",
   "sessionExpired": "Sessione scaduta",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "発行者",
   "codeIssuerHint": "発行者",
   "codeSecretKeyHint": "秘密鍵",
   "codeSecretKeyHint": "秘密鍵",
   "codeAccountHint": "アカウント (you@domain.com)",
   "codeAccountHint": "アカウント (you@domain.com)",
-  "accountKeyType": "鍵の種類",
   "sessionExpired": "セッションが失効しました",
   "sessionExpired": "セッションが失効しました",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "მომწოდებელი",
   "codeIssuerHint": "მომწოდებელი",
   "codeSecretKeyHint": "გასაღები",
   "codeSecretKeyHint": "გასაღები",
   "codeAccountHint": "ანგარიში (you@domain.com)",
   "codeAccountHint": "ანგარიში (you@domain.com)",
-  "accountKeyType": "გასაღების ტიპი",
   "sessionExpired": "სესიის დრო ამოიწურა",
   "sessionExpired": "სესიის დრო ამოიწურა",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Uitgever",
   "codeIssuerHint": "Uitgever",
   "codeSecretKeyHint": "Geheime sleutel",
   "codeSecretKeyHint": "Geheime sleutel",
   "codeAccountHint": "Account (jij@domein.nl)",
   "codeAccountHint": "Account (jij@domein.nl)",
-  "accountKeyType": "Type sleutel",
   "sessionExpired": "Sessie verlopen",
   "sessionExpired": "Sessie verlopen",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Wydawca",
   "codeIssuerHint": "Wydawca",
   "codeSecretKeyHint": "Tajny klucz",
   "codeSecretKeyHint": "Tajny klucz",
   "codeAccountHint": "Konto (ty@domena.com)",
   "codeAccountHint": "Konto (ty@domena.com)",
-  "accountKeyType": "Rodzaj klucza",
   "sessionExpired": "Sesja wygasła",
   "sessionExpired": "Sesja wygasła",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Emissor",
   "codeIssuerHint": "Emissor",
   "codeSecretKeyHint": "Chave secreta",
   "codeSecretKeyHint": "Chave secreta",
   "codeAccountHint": "Conta (voce@dominio.com)",
   "codeAccountHint": "Conta (voce@dominio.com)",
-  "accountKeyType": "Tipo de chave",
   "sessionExpired": "Sessão expirada",
   "sessionExpired": "Sessão expirada",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Эмитент",
   "codeIssuerHint": "Эмитент",
   "codeSecretKeyHint": "Секретный ключ",
   "codeSecretKeyHint": "Секретный ключ",
   "codeAccountHint": "Аккаунт (you@domain.com)",
   "codeAccountHint": "Аккаунт (you@domain.com)",
-  "accountKeyType": "Тип ключа",
   "sessionExpired": "Сеанс истек",
   "sessionExpired": "Сеанс истек",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -16,7 +16,6 @@
   "codeIssuerHint": "Utfärdare",
   "codeIssuerHint": "Utfärdare",
   "codeSecretKeyHint": "Secret Key",
   "codeSecretKeyHint": "Secret Key",
   "codeAccountHint": "Konto (du@domän.com)",
   "codeAccountHint": "Konto (du@domän.com)",
-  "accountKeyType": "Typ av nyckel",
   "sessionExpired": "Sessionen har gått ut",
   "sessionExpired": "Sessionen har gått ut",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "ኣዋጂ",
   "codeIssuerHint": "ኣዋጂ",
   "codeSecretKeyHint": "ምስጢራዊ መፍትሕ",
   "codeSecretKeyHint": "ምስጢራዊ መፍትሕ",
   "codeAccountHint": "ሕሳብ (you@domain.com)",
   "codeAccountHint": "ሕሳብ (you@domain.com)",
-  "accountKeyType": "ዓይነት መፍትሕ",
   "sessionExpired": "ክፍለ ግዜኡ ኣኺሉ።",
   "sessionExpired": "ክፍለ ግዜኡ ኣኺሉ።",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Yayınlayan",
   "codeIssuerHint": "Yayınlayan",
   "codeSecretKeyHint": "Gizli Anahtar",
   "codeSecretKeyHint": "Gizli Anahtar",
   "codeAccountHint": "Hesap (ornek@domain.com)",
   "codeAccountHint": "Hesap (ornek@domain.com)",
-  "accountKeyType": "Anahtar türü",
   "sessionExpired": "Oturum süresi doldu",
   "sessionExpired": "Oturum süresi doldu",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "Nhà phát hành",
   "codeIssuerHint": "Nhà phát hành",
   "codeSecretKeyHint": "Khóa bí mật",
   "codeSecretKeyHint": "Khóa bí mật",
   "codeAccountHint": "Tài khoản (bạn@miền.com)",
   "codeAccountHint": "Tài khoản (bạn@miền.com)",
-  "accountKeyType": "Loại khóa",
   "sessionExpired": "Phiên làm việc đã hết hạn",
   "sessionExpired": "Phiên làm việc đã hết hạn",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

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

@@ -20,7 +20,6 @@
   "codeIssuerHint": "发行人",
   "codeIssuerHint": "发行人",
   "codeSecretKeyHint": "私钥",
   "codeSecretKeyHint": "私钥",
   "codeAccountHint": "账户 (you@domain.com)",
   "codeAccountHint": "账户 (you@domain.com)",
-  "accountKeyType": "密钥类型",
   "sessionExpired": "会话已过期",
   "sessionExpired": "会话已过期",
   "@sessionExpired": {
   "@sessionExpired": {
     "description": "Title of the dialog when the users current session is invalid/expired"
     "description": "Title of the dialog when the users current session is invalid/expired"

+ 1 - 0
auth/lib/main.dart

@@ -37,6 +37,7 @@ import 'package:window_manager/window_manager.dart';
 final _logger = Logger("main");
 final _logger = Logger("main");
 
 
 Future<void> initSystemTray() async {
 Future<void> initSystemTray() async {
+  if (PlatformUtil.isMobile()) return;
   String path = Platform.isWindows
   String path = Platform.isWindows
       ? 'assets/icons/auth-icon.ico'
       ? 'assets/icons/auth-icon.ico'
       : 'assets/icons/auth-icon.png';
       : 'assets/icons/auth-icon.png';

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

@@ -2,6 +2,7 @@ import 'package:ente_auth/utils/totp_util.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;
@@ -57,36 +58,42 @@ 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,
     );
     );
   }
   }
 
 
   static Code fromAccountAndSecret(
   static Code fromAccountAndSecret(
+    Type type,
     String account,
     String account,
     String issuer,
     String issuer,
     String secret,
     String secret,
+    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",
     );
     );
   }
   }
 
 
   static Code fromRawData(String rawData) {
   static Code fromRawData(String rawData) {
     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),
@@ -140,10 +147,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;
     }
     }
   }
   }
@@ -186,6 +196,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;
     }
     }
@@ -223,6 +235,9 @@ class Code {
 enum Type {
 enum Type {
   totp,
   totp,
   hotp,
   hotp,
+  steam;
+
+  bool get isTOTPCompatible => this == totp || this == steam;
 }
 }
 
 
 enum Algorithm {
 enum Algorithm {

+ 10 - 3
auth/lib/onboarding/view/setup_enter_secret_key_page.dart

@@ -61,6 +61,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,
@@ -78,6 +80,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(() {
@@ -105,12 +109,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(height: 40),
                 SizedBox(
                 SizedBox(
                   width: 400,
                   width: 400,
                   child: OutlinedButton(
                   child: OutlinedButton(
@@ -152,6 +156,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,
@@ -168,9 +173,11 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
       }
       }
       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,
             )
             )
           : widget.code!.copyWith(
           : widget.code!.copyWith(
               account: account,
               account: account,

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

@@ -53,7 +53,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();
         }
         }
       }
       }
@@ -78,7 +78,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;
@@ -213,7 +213,7 @@ class _CodeWidgetState extends State<CodeWidget> {
         crossAxisAlignment: CrossAxisAlignment.start,
         crossAxisAlignment: CrossAxisAlignment.start,
         mainAxisAlignment: MainAxisAlignment.center,
         mainAxisAlignment: MainAxisAlignment.center,
         children: [
         children: [
-          if (widget.code.type == Type.totp)
+          if (widget.code.type.isTOTPCompatible)
             CodeTimerProgress(
             CodeTimerProgress(
               period: widget.code.period,
               period: widget.code.period,
             ),
             ),
@@ -263,7 +263,7 @@ class _CodeWidgetState extends State<CodeWidget> {
               },
               },
             ),
             ),
           ),
           ),
-          widget.code.type == Type.totp
+          widget.code.type.isTOTPCompatible
               ? GestureDetector(
               ? GestureDetector(
                   onTap: () {
                   onTap: () {
                     _copyNextToClipboard();
                     _copyNextToClipboard();
@@ -481,7 +481,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;

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

@@ -92,9 +92,11 @@ 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,
+          Code.defaultDigits,
         );
         );
       }
       }
 
 

+ 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

+ 3 - 3
auth/pubspec.lock

@@ -293,9 +293,9 @@ packages:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:
       path: "packages/desktop_webview_window"
       path: "packages/desktop_webview_window"
-      ref: HEAD
-      resolved-ref: "8cbbf9cd6efcfee5e0f420a36f7f8e7e64b667a1"
-      url: "https://github.com/MixinNetwork/flutter-plugins"
+      ref: fix-webkit-version
+      resolved-ref: fe2223e4edfecdbb3a97bb9e3ced73db4ae9d979
+      url: "https://github.com/ente-io/flutter-desktopwebview-fork"
     source: git
     source: git
     version: "0.2.4"
     version: "0.2.4"
   device_info_plus:
   device_info_plus:

+ 3 - 2
auth/pubspec.yaml

@@ -1,6 +1,6 @@
 name: ente_auth
 name: ente_auth
 description: ente two-factor authenticator
 description: ente two-factor authenticator
-version: 2.0.55+255
+version: 2.0.57+257
 publish_to: none
 publish_to: none
 
 
 environment:
 environment:
@@ -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

+ 2 - 1
cli/cmd/account.go

@@ -27,7 +27,8 @@ var listAccCmd = &cobra.Command{
 // Subcommand for 'account add'
 // Subcommand for 'account add'
 var addAccCmd = &cobra.Command{
 var addAccCmd = &cobra.Command{
 	Use:   "add",
 	Use:   "add",
-	Short: "Add a new account",
+	Short: "login into existing account",
+	Long:  "Use this command to add an existing account to cli. For creating a new account, use the mobile,web or desktop app",
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
 		recoverWithLog()
 		recoverWithLog()
 		ctrl.AddAccount(context.Background())
 		ctrl.AddAccount(context.Background())

+ 1 - 1
cli/docs/generated/ente.md

@@ -25,4 +25,4 @@ ente [flags]
 * [ente export](ente_export.md)	 - Starts the export process
 * [ente export](ente_export.md)	 - Starts the export process
 * [ente version](ente_version.md)	 - Prints the current version
 * [ente version](ente_version.md)	 - Prints the current version
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 2 - 2
cli/docs/generated/ente_account.md

@@ -11,9 +11,9 @@ Manage account settings
 ### SEE ALSO
 ### SEE ALSO
 
 
 * [ente](ente.md)	 - CLI tool for exporting your photos from ente.io
 * [ente](ente.md)	 - CLI tool for exporting your photos from ente.io
-* [ente account add](ente_account_add.md)	 - Add a new account
+* [ente account add](ente_account_add.md)	 - login into existing account
 * [ente account get-token](ente_account_get-token.md)	 - Get token for an account for a specific app
 * [ente account get-token](ente_account_get-token.md)	 - Get token for an account for a specific app
 * [ente account list](ente_account_list.md)	 - list configured accounts
 * [ente account list](ente_account_list.md)	 - list configured accounts
 * [ente account update](ente_account_update.md)	 - Update an existing account's export directory
 * [ente account update](ente_account_update.md)	 - Update an existing account's export directory
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 6 - 2
cli/docs/generated/ente_account_add.md

@@ -1,6 +1,10 @@
 ## ente account add
 ## ente account add
 
 
-Add a new account
+login into existing account
+
+### Synopsis
+
+Use this command to add an existing account to cli. For creating a new account, use the mobile,web or desktop app
 
 
 ```
 ```
 ente account add [flags]
 ente account add [flags]
@@ -16,4 +20,4 @@ ente account add [flags]
 
 
 * [ente account](ente_account.md)	 - Manage account settings
 * [ente account](ente_account.md)	 - Manage account settings
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_account_get-token.md

@@ -18,4 +18,4 @@ ente account get-token [flags]
 
 
 * [ente account](ente_account.md)	 - Manage account settings
 * [ente account](ente_account.md)	 - Manage account settings
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_account_list.md

@@ -16,4 +16,4 @@ ente account list [flags]
 
 
 * [ente account](ente_account.md)	 - Manage account settings
 * [ente account](ente_account.md)	 - Manage account settings
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_account_update.md

@@ -19,4 +19,4 @@ ente account update [flags]
 
 
 * [ente account](ente_account.md)	 - Manage account settings
 * [ente account](ente_account.md)	 - Manage account settings
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_admin.md

@@ -21,4 +21,4 @@ Commands for admin actions like disable or enabling 2fa, bumping up the storage
 * [ente admin list-users](ente_admin_list-users.md)	 - List all users
 * [ente admin list-users](ente_admin_list-users.md)	 - List all users
 * [ente admin update-subscription](ente_admin_update-subscription.md)	 - Update subscription for user
 * [ente admin update-subscription](ente_admin_update-subscription.md)	 - Update subscription for user
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_admin_delete-user.md

@@ -18,4 +18,4 @@ ente admin delete-user [flags]
 
 
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_admin_disable-2fa.md

@@ -18,4 +18,4 @@ ente admin disable-2fa [flags]
 
 
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_admin_get-user-id.md

@@ -18,4 +18,4 @@ ente admin get-user-id [flags]
 
 
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_admin_list-users.md

@@ -17,4 +17,4 @@ ente admin list-users [flags]
 
 
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_admin_update-subscription.md

@@ -23,4 +23,4 @@ ente admin update-subscription [flags]
 
 
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 * [ente admin](ente_admin.md)	 - Commands for admin actions
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_auth.md

@@ -13,4 +13,4 @@ Authenticator commands
 * [ente](ente.md)	 - CLI tool for exporting your photos from ente.io
 * [ente](ente.md)	 - CLI tool for exporting your photos from ente.io
 * [ente auth decrypt](ente_auth_decrypt.md)	 - Decrypt authenticator export
 * [ente auth decrypt](ente_auth_decrypt.md)	 - Decrypt authenticator export
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_auth_decrypt.md

@@ -16,4 +16,4 @@ ente auth decrypt [input] [output] [flags]
 
 
 * [ente auth](ente_auth.md)	 - Authenticator commands
 * [ente auth](ente_auth.md)	 - Authenticator commands
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_export.md

@@ -16,4 +16,4 @@ ente export [flags]
 
 
 * [ente](ente.md)	 - CLI tool for exporting your photos from ente.io
 * [ente](ente.md)	 - CLI tool for exporting your photos from ente.io
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/docs/generated/ente_version.md

@@ -16,4 +16,4 @@ ente version [flags]
 
 
 * [ente](ente.md)	 - CLI tool for exporting your photos from ente.io
 * [ente](ente.md)	 - CLI tool for exporting your photos from ente.io
 
 
-###### Auto generated by spf13/cobra on 14-Mar-2024
+###### Auto generated by spf13/cobra on 6-May-2024

+ 1 - 1
cli/pkg/account.go

@@ -59,7 +59,7 @@ func (c *ClICtrl) AddAccount(cxt context.Context) {
 		authResponse, flowErr = c.validateTOTP(cxt, authResponse)
 		authResponse, flowErr = c.validateTOTP(cxt, authResponse)
 	}
 	}
 	if authResponse.EncryptedToken == "" || authResponse.KeyAttributes == nil {
 	if authResponse.EncryptedToken == "" || authResponse.KeyAttributes == nil {
-		panic("no encrypted token or keyAttributes")
+		log.Fatalf("missing key attributes or token.\nNote: Please use the mobile,web or desktop app to create a new account.\nIf you are trying to login to an existing account, report a bug.")
 	}
 	}
 	secretInfo, decErr := c.decryptAccSecretInfo(cxt, authResponse, keyEncKey)
 	secretInfo, decErr := c.decryptAccSecretInfo(cxt, authResponse, keyEncKey)
 	if decErr != nil {
 	if decErr != nil {

+ 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

+ 70 - 0
desktop/.github/workflows/desktop-draft-release.yml

@@ -0,0 +1,70 @@
+name: "Draft release"
+
+# Build the desktop/draft-release branch and update the existing draft release
+# with the resultant artifacts.
+#
+# This is meant for doing tests that require the app to be signed and packaged.
+# Such releases should not be published to end users.
+#
+# Workflow:
+#
+# 1. Push your changes to the "desktop/draft-release" branch on
+#    https://github.com/ente-io/ente.
+#
+# 2. Create a draft release with tag equal to the version in the `package.json`.
+#
+# 3. Trigger this workflow. You can trigger it multiple times, each time it'll
+#    just update the artifacts attached to the same draft.
+#
+# 4. Once testing is done delete the draft.
+
+on:
+    # Trigger manually or `gh workflow run desktop-draft-release.yml`.
+    workflow_dispatch:
+
+jobs:
+    release:
+        runs-on: macos-latest
+
+        defaults:
+            run:
+                working-directory: desktop
+
+        steps:
+            - name: Checkout code
+              uses: actions/checkout@v4
+              with:
+                  repository: ente-io/ente
+                  ref: desktop/draft-release
+                  submodules: recursive
+
+            - name: Setup node
+              uses: actions/setup-node@v4
+              with:
+                  node-version: 20
+
+            - name: Install dependencies
+              run: yarn install
+
+            - 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

+ 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

+ 6 - 3
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,16 +90,19 @@ 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
 
 
-The main tool we use is for arbitrary conversions is FFMPEG. To bundle a
+The main tool we use is for arbitrary conversions is ffmpeg. To bundle a
 (platform specific) static binary of ffmpeg with our app, we use
 (platform specific) static binary of ffmpeg with our app, we use
 [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static).
 [ffmpeg-static](https://github.com/eugeneware/ffmpeg-static).
 
 
 > There is a significant (~20x) speed difference between using the compiled
 > There is a significant (~20x) speed difference between using the compiled
-> FFMPEG binary and using the WASM one (that our renderer process already has).
+> ffmpeg binary and using the wasm one (that our renderer process already has).
 > Which is why we bundle it to speed up operations on the desktop app.
 > Which is why we bundle it to speed up operations on the desktop app.
 
 
 In addition, we also bundle a static Linux binary of imagemagick in our extra
 In addition, we also bundle a static Linux binary of imagemagick in our extra

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

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

+ 144 - 85
desktop/src/main.ts

@@ -8,32 +8,30 @@
  *
  *
  * 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";
 import { setupAutoUpdater } from "./main/services/app-update";
 import { setupAutoUpdater } from "./main/services/app-update";
-import autoLauncher from "./main/services/autoLauncher";
-import { initWatcher } from "./main/services/chokidar";
+import autoLauncher from "./main/services/auto-launcher";
+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 { registerStreamProtocol } from "./main/stream";
 import { registerStreamProtocol } from "./main/stream";
-import { isDev } from "./main/util";
+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
@@ -129,54 +127,22 @@ 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: {
-            preload: path.join(app.getAppPath(), "preload.js"),
+            preload: path.join(__dirname, "preload.js"),
             sandbox: true,
             sandbox: true,
         },
         },
         // The color to show in the window until the web content gets loaded.
         // The color to show in the window until the web content gets loaded.
@@ -186,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.
@@ -196,19 +162,19 @@ const createMainWindow = async () => {
         window.maximize();
         window.maximize();
     }
     }
 
 
-    window.loadURL(rendererURL);
-
     // Open the DevTools automatically when running in dev mode
     // Open the DevTools automatically when running in dev mode
     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();
     });
     });
 
 
+    // "The unresponsive event is fired when Chromium detects that your
+    //  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();
     });
     });
@@ -229,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
@@ -239,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.
  *
  *
@@ -269,30 +287,61 @@ 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 removed in favor of cache on the web layer.
  *
  *
- * See [Note: Increased disk cache for the desktop app]
+ * Delete the old cache dir if it exists.
  *
  *
- * 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
- * versions.
+ * This will happen in two phases. The cache had three subdirectories:
+ *
+ * - Two of them, "thumbs" and "files", will be removed now (v1.7.0, May 2024).
+ *
+ * - The third one, "face-crops" will be removed once we finish the face search
+ *   changes. See: [Note: Legacy face crops].
+ *
+ * This migration code can be removed after some time once most people have
+ * upgraded to newer 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`.
+    const removeIfExists = async (dirPath: string) => {
+        if (existsSync(dirPath)) {
+            log.info(`Removing legacy disk cache from ${dirPath}`);
+            await fs.rm(dirPath, { recursive: true });
+        }
+    };
+    // [Note: Getting the cache path]
+    //
+    // The existing code was passing "cache" as a parameter to getPath.
+    //
+    // 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:
     //
     //
-    // It might be an issue in the types, since at runtime it seems to work. For
-    // example, on macOS I get `~/Library/Caches`.
+    // - 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}`);
-        await fs.rm(cacheDir, { recursive: true });
+        await removeIfExists(path.join(cacheDir, "thumbs"));
+        await removeIfExists(path.join(cacheDir, "files"));
+    }
+};
+
+/**
+ * Older versions of our app used to keep a keys.json. It is not needed anymore,
+ * remove it if it exists.
+ *
+ * This code was added March 2024, and can be removed after some time once most
+ * people have upgraded to newer versions.
+ */
+const deleteLegacyKeysStoreIfExists = async () => {
+    const keysStore = path.join(app.getPath("userData"), "keys.json");
+    if (existsSync(keysStore)) {
+        log.info(`Removing legacy keys store at ${keysStore}`);
+        await fs.rm(keysStore);
     }
     }
 };
 };
 
 
@@ -310,7 +359,7 @@ 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();
 
 
     app.on("second-instance", () => {
     app.on("second-instance", () => {
         // Someone tried to run a second instance, we should focus our window.
         // Someone tried to run a second instance, we should focus our window.
@@ -324,25 +373,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 () => {
-        mainWindow = await createMainWindow();
-        Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
-        setupTrayItem(mainWindow);
-        attachIPCHandlers();
-        attachFSWatchIPCHandlers(initWatcher(mainWindow));
-        registerStreamProtocol();
-        if (!isDev) setupAutoUpdater(mainWindow);
-        handleDownloads(mainWindow);
-        handleExternalLinks(mainWindow);
-        addAllowOriginHeader(mainWindow);
-
-        try {
-            deleteLegacyDiskCacheDirIfExists();
-        } 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);
+            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);
-
-/* TODO: Audit below this  */
-
-export const isFolder = async (dirPath: string) => {
-    if (!existsSync(dirPath)) return false;
-    const stats = await fs.stat(dirPath);
-    return stats.isDirectory();
-};

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

+ 115 - 95
desktop/src/main/ipc.ts

@@ -10,37 +10,41 @@
 
 
 import type { FSWatcher } from "chokidar";
 import type { FSWatcher } from "chokidar";
 import { ipcMain } from "electron/main";
 import { ipcMain } from "electron/main";
-import type { ElectronFile, FILE_PATH_TYPE, FolderWatch } from "../types/ipc";
+import type {
+    CollectionMapping,
+    FolderWatch,
+    PendingUploads,
+    ZipItem,
+} from "../types/ipc";
+import { logToDisk } from "./log";
+import {
+    appVersion,
+    skipAppUpdate,
+    updateAndRestart,
+    updateOnNextRestart,
+} from "./services/app-update";
 import {
 import {
+    legacyFaceCrop,
+    openDirectory,
+    openLogDirectory,
     selectDirectory,
     selectDirectory,
-    showUploadDirsDialog,
-    showUploadFilesDialog,
-    showUploadZipDialog,
-} from "./dialogs";
+} from "./services/dir";
+import { ffmpegExec } from "./services/ffmpeg";
 import {
 import {
     fsExists,
     fsExists,
+    fsIsDir,
     fsMkdirIfNeeded,
     fsMkdirIfNeeded,
     fsReadTextFile,
     fsReadTextFile,
     fsRename,
     fsRename,
     fsRm,
     fsRm,
     fsRmdir,
     fsRmdir,
     fsWriteFile,
     fsWriteFile,
-    isFolder,
-} from "./fs";
-import { logToDisk } from "./log";
+} from "./services/fs";
+import { convertToJPEG, generateImageThumbnail } from "./services/image";
 import {
 import {
-    appVersion,
-    skipAppUpdate,
-    updateAndRestart,
-    updateOnNextRestart,
-} from "./services/app-update";
-import { runFFmpegCmd } from "./services/ffmpeg";
-import { getDirFiles } from "./services/fs";
-import {
-    convertToJPEG,
-    generateImageThumbnail,
-} from "./services/imageProcessor";
-import { clipImageEmbedding, clipTextEmbedding } from "./services/ml-clip";
+    clipImageEmbedding,
+    clipTextEmbeddingIfAvailable,
+} from "./services/ml-clip";
 import { detectFaces, faceEmbedding } from "./services/ml-face";
 import { detectFaces, faceEmbedding } from "./services/ml-face";
 import {
 import {
     clearStores,
     clearStores,
@@ -48,19 +52,23 @@ import {
     saveEncryptionKey,
     saveEncryptionKey,
 } from "./services/store";
 } from "./services/store";
 import {
 import {
-    getElectronFilesFromGoogleZip,
-    getPendingUploads,
-    setToUploadCollection,
-    setToUploadFiles,
+    clearPendingUploads,
+    listZipItems,
+    markUploadedFiles,
+    markUploadedZipItems,
+    pathOrZipItemSize,
+    pendingUploads,
+    setPendingUploads,
 } from "./services/upload";
 } from "./services/upload";
 import {
 import {
-    addWatchMapping,
-    getWatchMappings,
-    removeWatchMapping,
-    updateWatchMappingIgnoredFiles,
-    updateWatchMappingSyncedFiles,
+    watchAdd,
+    watchFindFiles,
+    watchGet,
+    watchRemove,
+    watchReset,
+    watchUpdateIgnoredFiles,
+    watchUpdateSyncedFiles,
 } from "./services/watch";
 } from "./services/watch";
-import { openDirectory, openLogDirectory } from "./util";
 
 
 /**
 /**
  * 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
@@ -87,16 +95,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),
     );
     );
 
 
@@ -106,21 +118,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));
 
 
@@ -132,27 +146,39 @@ export const attachIPCHandlers = () => {
         fsWriteFile(path, contents),
         fsWriteFile(path, contents),
     );
     );
 
 
+    ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath));
+
     // - Conversion
     // - Conversion
 
 
-    ipcMain.handle("convertToJPEG", (_, fileData, filename) =>
-        convertToJPEG(fileData, filename),
+    ipcMain.handle("convertToJPEG", (_, imageData: Uint8Array) =>
+        convertToJPEG(imageData),
     );
     );
 
 
     ipcMain.handle(
     ipcMain.handle(
         "generateImageThumbnail",
         "generateImageThumbnail",
-        (_, inputFile, maxDimension, maxSize) =>
-            generateImageThumbnail(inputFile, maxDimension, maxSize),
+        (
+            _,
+            dataOrPathOrZipItem: Uint8Array | string | ZipItem,
+            maxDimension: number,
+            maxSize: number,
+        ) => generateImageThumbnail(dataOrPathOrZipItem, maxDimension, maxSize),
     );
     );
 
 
     ipcMain.handle(
     ipcMain.handle(
-        "runFFmpegCmd",
+        "ffmpegExec",
         (
         (
             _,
             _,
-            cmd: string[],
-            inputFile: File | ElectronFile,
-            outputFileName: string,
-            dontTimeout?: boolean,
-        ) => runFFmpegCmd(cmd, inputFile, outputFileName, dontTimeout),
+            command: string[],
+            dataOrPathOrZipItem: Uint8Array | string | ZipItem,
+            outputFileExtension: string,
+            timeoutMS: number,
+        ) =>
+            ffmpegExec(
+                command,
+                dataOrPathOrZipItem,
+                outputFileExtension,
+                timeoutMS,
+            ),
     );
     );
 
 
     // - ML
     // - ML
@@ -161,8 +187,8 @@ export const attachIPCHandlers = () => {
         clipImageEmbedding(jpegImageData),
         clipImageEmbedding(jpegImageData),
     );
     );
 
 
-    ipcMain.handle("clipTextEmbedding", (_, text: string) =>
-        clipTextEmbedding(text),
+    ipcMain.handle("clipTextEmbeddingIfAvailable", (_, text: string) =>
+        clipTextEmbeddingIfAvailable(text),
     );
     );
 
 
     ipcMain.handle("detectFaces", (_, input: Float32Array) =>
     ipcMain.handle("detectFaces", (_, input: Float32Array) =>
@@ -173,39 +199,37 @@ export const attachIPCHandlers = () => {
         faceEmbedding(input),
         faceEmbedding(input),
     );
     );
 
 
-    // - File selection
-
-    ipcMain.handle("selectDirectory", () => selectDirectory());
-
-    ipcMain.handle("showUploadFilesDialog", () => showUploadFilesDialog());
-
-    ipcMain.handle("showUploadDirsDialog", () => showUploadDirsDialog());
-
-    ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog());
+    ipcMain.handle("legacyFaceCrop", (_, faceID: string) =>
+        legacyFaceCrop(faceID),
+    );
 
 
-    // - FS Legacy
+    // - Upload
 
 
-    ipcMain.handle("isFolder", (_, dirPath: string) => isFolder(dirPath));
+    ipcMain.handle("listZipItems", (_, zipPath: string) =>
+        listZipItems(zipPath),
+    );
 
 
-    // - Upload
+    ipcMain.handle("pathOrZipItemSize", (_, pathOrZipItem: string | ZipItem) =>
+        pathOrZipItemSize(pathOrZipItem),
+    );
 
 
-    ipcMain.handle("getPendingUploads", () => getPendingUploads());
+    ipcMain.handle("pendingUploads", () => pendingUploads());
 
 
-    ipcMain.handle(
-        "setToUploadFiles",
-        (_, type: FILE_PATH_TYPE, filePaths: string[]) =>
-            setToUploadFiles(type, filePaths),
+    ipcMain.handle("setPendingUploads", (_, pendingUploads: PendingUploads) =>
+        setPendingUploads(pendingUploads),
     );
     );
 
 
-    ipcMain.handle("getElectronFilesFromGoogleZip", (_, filePath: string) =>
-        getElectronFilesFromGoogleZip(filePath),
+    ipcMain.handle(
+        "markUploadedFiles",
+        (_, paths: PendingUploads["filePaths"]) => markUploadedFiles(paths),
     );
     );
 
 
-    ipcMain.handle("setToUploadCollection", (_, collectionName: string) =>
-        setToUploadCollection(collectionName),
+    ipcMain.handle(
+        "markUploadedZipItems",
+        (_, items: PendingUploads["zipItems"]) => markUploadedZipItems(items),
     );
     );
 
 
-    ipcMain.handle("getDirFiles", (_, dirPath: string) => getDirFiles(dirPath));
+    ipcMain.handle("clearPendingUploads", () => clearPendingUploads());
 };
 };
 
 
 /**
 /**
@@ -213,42 +237,38 @@ export const attachIPCHandlers = () => {
  * watch folder functionality.
  * watch folder functionality.
  *
  *
  * It gets passed a {@link FSWatcher} instance which it can then forward to the
  * It gets passed a {@link FSWatcher} instance which it can then forward to the
- * actual handlers.
+ * actual handlers if they need access to it to do their thing.
  */
  */
 export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
 export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
     // - Watch
     // - Watch
 
 
+    ipcMain.handle("watchGet", () => watchGet(watcher));
+
     ipcMain.handle(
     ipcMain.handle(
-        "addWatchMapping",
-        (
-            _,
-            collectionName: string,
-            folderPath: string,
-            uploadStrategy: number,
-        ) =>
-            addWatchMapping(
-                watcher,
-                collectionName,
-                folderPath,
-                uploadStrategy,
-            ),
+        "watchAdd",
+        (_, folderPath: string, collectionMapping: CollectionMapping) =>
+            watchAdd(watcher, folderPath, collectionMapping),
     );
     );
 
 
-    ipcMain.handle("removeWatchMapping", (_, folderPath: string) =>
-        removeWatchMapping(watcher, folderPath),
+    ipcMain.handle("watchRemove", (_, folderPath: string) =>
+        watchRemove(watcher, folderPath),
     );
     );
 
 
-    ipcMain.handle("getWatchMappings", () => getWatchMappings());
-
     ipcMain.handle(
     ipcMain.handle(
-        "updateWatchMappingSyncedFiles",
-        (_, folderPath: string, files: FolderWatch["syncedFiles"]) =>
-            updateWatchMappingSyncedFiles(folderPath, files),
+        "watchUpdateSyncedFiles",
+        (_, syncedFiles: FolderWatch["syncedFiles"], folderPath: string) =>
+            watchUpdateSyncedFiles(syncedFiles, folderPath),
     );
     );
 
 
     ipcMain.handle(
     ipcMain.handle(
-        "updateWatchMappingIgnoredFiles",
-        (_, folderPath: string, files: FolderWatch["ignoredFiles"]) =>
-            updateWatchMappingIgnoredFiles(folderPath, files),
+        "watchUpdateIgnoredFiles",
+        (_, ignoredFiles: FolderWatch["ignoredFiles"], folderPath: string) =>
+            watchUpdateIgnoredFiles(ignoredFiles, folderPath),
     );
     );
+
+    ipcMain.handle("watchFindFiles", (_, folderPath: string) =>
+        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 "./util";
+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)}`);

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

@@ -7,9 +7,9 @@ import {
 } from "electron";
 } from "electron";
 import { allowWindowClose } from "../main";
 import { allowWindowClose } from "../main";
 import { forceCheckForAppUpdates } from "./services/app-update";
 import { forceCheckForAppUpdates } from "./services/app-update";
-import autoLauncher from "./services/autoLauncher";
+import autoLauncher from "./services/auto-launcher";
+import { openLogDirectory } from "./services/dir";
 import { userPreferences } from "./stores/user-preferences";
 import { userPreferences } from "./stores/user-preferences";
-import { openLogDirectory } from "./util";
 
 
 /** 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 +18,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 : [];
@@ -26,12 +26,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;
     };
     };
 
 
@@ -42,13 +42,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([
         {
         {
@@ -124,11 +126,11 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
                         submenu: [
                         submenu: [
                             {
                             {
                                 role: "startSpeaking",
                                 role: "startSpeaking",
-                                label: "start speaking",
+                                label: "Start Speaking",
                             },
                             },
                             {
                             {
                                 role: "stopSpeaking",
                                 role: "stopSpeaking",
-                                label: "stop speaking",
+                                label: "Stop Speaking",
                             },
                             },
                         ],
                         ],
                     },
                     },

+ 0 - 19
desktop/src/main/platform.ts

@@ -1,19 +0,0 @@
-export function isPlatform(platform: "mac" | "windows" | "linux") {
-    return getPlatform() === platform;
-}
-
-export function getPlatform(): "mac" | "windows" | "linux" {
-    switch (process.platform) {
-        case "aix":
-        case "freebsd":
-        case "linux":
-        case "openbsd":
-        case "android":
-            return "linux";
-        case "darwin":
-        case "sunos":
-            return "mac";
-        case "win32":
-            return "windows";
-    }
-}

+ 25 - 13
desktop/src/main/services/app-update.ts

@@ -1,19 +1,28 @@
 import { compareVersions } from "compare-versions";
 import { compareVersions } from "compare-versions";
-import { app, BrowserWindow } from "electron";
 import { default as electronLog } from "electron-log";
 import { default as electronLog } from "electron-log";
 import { autoUpdater } from "electron-updater";
 import { autoUpdater } from "electron-updater";
+import { app, BrowserWindow } from "electron/main";
 import { allowWindowClose } from "../../main";
 import { allowWindowClose } from "../../main";
-import { AppUpdateInfo } from "../../types/ipc";
+import { AppUpdate } from "../../types/ipc";
 import log from "../log";
 import log from "../log";
 import { userPreferences } from "../stores/user-preferences";
 import { userPreferences } from "../stores/user-preferences";
+import { isDev } from "../utils/electron";
 
 
 export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
 export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
     autoUpdater.logger = electronLog;
     autoUpdater.logger = electronLog;
     autoUpdater.autoDownload = false;
     autoUpdater.autoDownload = false;
 
 
+    // Skip checking for updates automatically in dev builds. Installing an
+    // update would fail anyway since (at least on macOS), the auto update
+    // process requires signed builds.
+    //
+    // Even though this is skipped on app start, we can still use the "Check for
+    // updates..." menu option to trigger the update if we wish in dev builds.
+    if (isDev) return;
+
     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 +31,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,39 +45,42 @@ 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;
     }
     }
 
 
-    const showUpdateDialog = (updateInfo: AppUpdateInfo) =>
-        mainWindow.webContents.send("appUpdateAvailable", updateInfo);
+    const showUpdateDialog = (update: AppUpdate) =>
+        mainWindow.webContents.send("appUpdateAvailable", update);
 
 
     log.debug(() => "Attempting auto update");
     log.debug(() => "Attempting auto update");
-    autoUpdater.downloadUpdate();
+    await autoUpdater.downloadUpdate();
 
 
-    let timeout: NodeJS.Timeout;
+    let timeoutId: ReturnType<typeof setTimeout>;
     const fiveMinutes = 5 * 60 * 1000;
     const fiveMinutes = 5 * 60 * 1000;
     autoUpdater.on("update-downloaded", () => {
     autoUpdater.on("update-downloaded", () => {
-        timeout = setTimeout(
+        timeoutId = setTimeout(
             () => showUpdateDialog({ autoUpdatable: true, version }),
             () => showUpdateDialog({ autoUpdatable: true, version }),
             fiveMinutes,
             fiveMinutes,
         );
         );
     });
     });
 
 
     autoUpdater.on("error", (error) => {
     autoUpdater.on("error", (error) => {
-        clearTimeout(timeout);
+        clearTimeout(timeoutId);
         log.error("Auto update failed", error);
         log.error("Auto update failed", error);
         showUpdateDialog({ autoUpdatable: false, version });
         showUpdateDialog({ autoUpdatable: false, version });
     });
     });

+ 50 - 0
desktop/src/main/services/auto-launcher.ts

@@ -0,0 +1,50 @@
+import AutoLaunch from "auto-launch";
+import { app } from "electron/main";
+
+class AutoLauncher {
+    /**
+     * This property will be set and used on Linux and Windows. On macOS,
+     * there's a separate API
+     */
+    private autoLaunch?: AutoLaunch;
+
+    constructor() {
+        if (process.platform != "darwin") {
+            this.autoLaunch = new AutoLaunch({
+                name: "ente",
+                isHidden: true,
+            });
+        }
+    }
+
+    async isEnabled() {
+        const autoLaunch = this.autoLaunch;
+        if (autoLaunch) {
+            return await autoLaunch.isEnabled();
+        } else {
+            return app.getLoginItemSettings().openAtLogin;
+        }
+    }
+
+    async toggleAutoLaunch() {
+        const wasEnabled = await this.isEnabled();
+        const autoLaunch = this.autoLaunch;
+        if (autoLaunch) {
+            if (wasEnabled) await autoLaunch.disable();
+            else await autoLaunch.enable();
+        } else {
+            const openAtLogin = !wasEnabled;
+            app.setLoginItemSettings({ openAtLogin });
+        }
+    }
+
+    wasAutoLaunched() {
+        if (this.autoLaunch) {
+            return app.commandLine.hasSwitch("hidden");
+        } else {
+            return app.getLoginItemSettings().openAtLogin;
+        }
+    }
+}
+
+export default new AutoLauncher();

+ 0 - 41
desktop/src/main/services/autoLauncher.ts

@@ -1,41 +0,0 @@
-import { AutoLauncherClient } from "../../types/main";
-import { isPlatform } from "../platform";
-import linuxAndWinAutoLauncher from "./autoLauncherClients/linuxAndWinAutoLauncher";
-import macAutoLauncher from "./autoLauncherClients/macAutoLauncher";
-
-class AutoLauncher {
-    private client: AutoLauncherClient;
-    async init() {
-        if (isPlatform("linux") || isPlatform("windows")) {
-            this.client = linuxAndWinAutoLauncher;
-        } else {
-            this.client = macAutoLauncher;
-        }
-        // migrate old auto launch settings for windows from mac auto launcher to linux and windows auto launcher
-        if (isPlatform("windows") && (await macAutoLauncher.isEnabled())) {
-            await macAutoLauncher.toggleAutoLaunch();
-            await linuxAndWinAutoLauncher.toggleAutoLaunch();
-        }
-    }
-    async isEnabled() {
-        if (!this.client) {
-            await this.init();
-        }
-        return await this.client.isEnabled();
-    }
-    async toggleAutoLaunch() {
-        if (!this.client) {
-            await this.init();
-        }
-        await this.client.toggleAutoLaunch();
-    }
-
-    async wasAutoLaunched() {
-        if (!this.client) {
-            await this.init();
-        }
-        return this.client.wasAutoLaunched();
-    }
-}
-
-export default new AutoLauncher();

+ 0 - 39
desktop/src/main/services/autoLauncherClients/linuxAndWinAutoLauncher.ts

@@ -1,39 +0,0 @@
-import AutoLaunch from "auto-launch";
-import { app } from "electron";
-import { AutoLauncherClient } from "../../../types/main";
-
-const LAUNCHED_AS_HIDDEN_FLAG = "hidden";
-
-class LinuxAndWinAutoLauncher implements AutoLauncherClient {
-    private instance: AutoLaunch;
-    constructor() {
-        const autoLauncher = new AutoLaunch({
-            name: "ente",
-            isHidden: true,
-        });
-        this.instance = autoLauncher;
-    }
-    async isEnabled() {
-        return await this.instance.isEnabled();
-    }
-    async toggleAutoLaunch() {
-        if (await this.isEnabled()) {
-            await this.disableAutoLaunch();
-        } else {
-            await this.enableAutoLaunch();
-        }
-    }
-
-    async wasAutoLaunched() {
-        return app.commandLine.hasSwitch(LAUNCHED_AS_HIDDEN_FLAG);
-    }
-
-    private async disableAutoLaunch() {
-        await this.instance.disable();
-    }
-    private async enableAutoLaunch() {
-        await this.instance.enable();
-    }
-}
-
-export default new LinuxAndWinAutoLauncher();

+ 0 - 28
desktop/src/main/services/autoLauncherClients/macAutoLauncher.ts

@@ -1,28 +0,0 @@
-import { app } from "electron";
-import { AutoLauncherClient } from "../../../types/main";
-
-class MacAutoLauncher implements AutoLauncherClient {
-    async isEnabled() {
-        return app.getLoginItemSettings().openAtLogin;
-    }
-    async toggleAutoLaunch() {
-        if (await this.isEnabled()) {
-            this.disableAutoLaunch();
-        } else {
-            this.enableAutoLaunch();
-        }
-    }
-
-    async wasAutoLaunched() {
-        return app.getLoginItemSettings().wasOpenedAtLogin;
-    }
-
-    private disableAutoLaunch() {
-        app.setLoginItemSettings({ openAtLogin: false });
-    }
-    private enableAutoLaunch() {
-        app.setLoginItemSettings({ openAtLogin: true });
-    }
-}
-
-export default new MacAutoLauncher();

+ 0 - 45
desktop/src/main/services/chokidar.ts

@@ -1,45 +0,0 @@
-import chokidar from "chokidar";
-import { BrowserWindow } from "electron";
-import path from "path";
-import log from "../log";
-import { getElectronFile } from "./fs";
-import { getWatchMappings } from "./watch";
-
-/**
- * Convert a file system {@link filePath} that uses the local system specific
- * path separators into a path that uses POSIX file separators.
- */
-const normalizeToPOSIX = (filePath: string) =>
-    filePath.split(path.sep).join(path.posix.sep);
-
-export function initWatcher(mainWindow: BrowserWindow) {
-    const mappings = getWatchMappings();
-    const folderPaths = mappings.map((mapping) => {
-        return mapping.folderPath;
-    });
-
-    const watcher = chokidar.watch(folderPaths, {
-        awaitWriteFinish: true,
-    });
-    watcher
-        .on("add", async (path) => {
-            mainWindow.webContents.send(
-                "watch-add",
-                await getElectronFile(normalizeToPOSIX(path)),
-            );
-        })
-        .on("unlink", (path) => {
-            mainWindow.webContents.send("watch-unlink", normalizeToPOSIX(path));
-        })
-        .on("unlinkDir", (path) => {
-            mainWindow.webContents.send(
-                "watch-unlink-dir",
-                normalizeToPOSIX(path),
-            );
-        })
-        .on("error", (error) => {
-            log.error("Error while watching files", error);
-        });
-
-    return watcher;
-}

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

@@ -0,0 +1,89 @@
+import { shell } from "electron/common";
+import { app, dialog } from "electron/main";
+import { existsSync } from "fs";
+import fs from "node:fs/promises";
+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]
+ *
+ * There are three paths we need to be aware of usually.
+ *
+ * First is the "appData". We can obtain this with `app.getPath("appData")`.
+ * This is per-user application data directory. This is usually the following:
+ *
+ * - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local`
+ * - Linux: `~/.config`
+ * - macOS: `~/Library/Application Support`
+ *
+ * Now, if we suffix the app's name onto the appData directory, we get the
+ * "userData" directory. This is the **primary** place applications are meant to
+ * store user's data, e.g. various configuration files and saved state.
+ *
+ * During development, our app name is "Electron", so this'd be, for example,
+ * `~/Library/Application Support/Electron` if we run using `yarn dev`. For the
+ * packaged production app, our app name is "ente", so this would be:
+ *
+ * - Windows: `%APPDATA%\ente`, e.g. `C:\Users\<username>\AppData\Local\ente`
+ * - Linux: `~/.config/ente`
+ * - macOS: `~/Library/Application Support/ente`
+ *
+ * Note that Chromium also stores the browser state, e.g. localStorage or disk
+ * caches, in userData.
+ *
+ * Finally, there is the "logs" directory. This is not within "appData" but has
+ * a slightly different OS specific path. Since our log file is named
+ * "ente.log", it can be found at:
+ *
+ * - macOS: ~/Library/Logs/ente/ente.log (production)
+ * - macOS: ~/Library/Logs/Electron/ente.log    (dev)
+ *
+ * https://www.electronjs.org/docs/latest/api/app
+ */
+const logDirectoryPath = () => app.getPath("logs");
+
+/**
+ * See: [Note: Legacy face crops]
+ */
+export const legacyFaceCrop = async (
+    faceID: string,
+): Promise<Uint8Array | undefined> => {
+    // See: [Note: Getting the cache path]
+    // @ts-expect-error "cache" works but is not part of the public API.
+    const cacheDir = path.join(app.getPath("cache"), "ente");
+    const filePath = path.join(cacheDir, "face-crops", faceID);
+    return existsSync(filePath) ? await fs.readFile(filePath) : undefined;
+};

+ 68 - 115
desktop/src/main/services/ffmpeg.ts

@@ -1,33 +1,37 @@
 import pathToFfmpeg from "ffmpeg-static";
 import pathToFfmpeg from "ffmpeg-static";
-import { existsSync } from "node:fs";
 import fs from "node:fs/promises";
 import fs from "node:fs/promises";
-import { ElectronFile } from "../../types/ipc";
+import type { ZipItem } from "../../types/ipc";
 import log from "../log";
 import log from "../log";
-import { writeStream } from "../stream";
-import { generateTempFilePath, getTempDirPath } from "../temp";
-import { execAsync } from "../util";
+import { ensure, withTimeout } from "../utils/common";
+import { execAsync } from "../utils/electron";
+import {
+    deleteTempFile,
+    makeFileForDataOrPathOrZipItem,
+    makeTempFilePath,
+} from "../utils/temp";
 
 
-const INPUT_PATH_PLACEHOLDER = "INPUT";
-const FFMPEG_PLACEHOLDER = "FFMPEG";
-const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
+/* Duplicated in the web app's code (used by the WASM FFmpeg implementation). */
+const ffmpegPathPlaceholder = "FFMPEG";
+const inputPathPlaceholder = "INPUT";
+const outputPathPlaceholder = "OUTPUT";
 
 
 /**
 /**
- * Run a ffmpeg command
+ * Run a FFmpeg command
  *
  *
- * [Note: FFMPEG in Electron]
+ * [Note: FFmpeg in Electron]
  *
  *
- * There is a wasm build of FFMPEG, but that is currently 10-20 times slower
+ * There is a wasm build of FFmpeg, but that is currently 10-20 times slower
  * that the native build. That is slow enough to be unusable for our purposes.
  * that the native build. That is slow enough to be unusable for our purposes.
  * https://ffmpegwasm.netlify.app/docs/performance
  * https://ffmpegwasm.netlify.app/docs/performance
  *
  *
- * So the alternative is to bundle a ffmpeg binary with our app. e.g.
+ * So the alternative is to bundle a FFmpeg executable binary with our app. e.g.
  *
  *
  *     yarn add fluent-ffmpeg ffmpeg-static ffprobe-static
  *     yarn add fluent-ffmpeg ffmpeg-static ffprobe-static
  *
  *
  * (we only use ffmpeg-static, the rest are mentioned for completeness' sake).
  * (we only use ffmpeg-static, the rest are mentioned for completeness' sake).
  *
  *
- * Interestingly, Electron already bundles an ffmpeg library (it comes from the
- * ffmpeg fork maintained by Chromium).
+ * Interestingly, Electron already bundles an binary FFmpeg library (it comes
+ * from the ffmpeg fork maintained by Chromium).
  * https://chromium.googlesource.com/chromium/third_party/ffmpeg
  * https://chromium.googlesource.com/chromium/third_party/ffmpeg
  * https://stackoverflow.com/questions/53963672/what-version-of-ffmpeg-is-bundled-inside-electron
  * https://stackoverflow.com/questions/53963672/what-version-of-ffmpeg-is-bundled-inside-electron
  *
  *
@@ -36,126 +40,75 @@ const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
  *     $ file ente.app/Contents/Frameworks/Electron\ Framework.framework/Versions/Current/Libraries/libffmpeg.dylib
  *     $ file ente.app/Contents/Frameworks/Electron\ Framework.framework/Versions/Current/Libraries/libffmpeg.dylib
  *     .../libffmpeg.dylib: Mach-O 64-bit dynamically linked shared library arm64
  *     .../libffmpeg.dylib: Mach-O 64-bit dynamically linked shared library arm64
  *
  *
- * I'm not sure if our code is supposed to be able to use it, and how.
+ * But I'm not sure if our code is supposed to be able to use it, and how.
  */
  */
-export async function runFFmpegCmd(
-    cmd: string[],
-    inputFile: File | ElectronFile,
-    outputFileName: string,
-    dontTimeout?: boolean,
-) {
-    let inputFilePath = null;
-    let createdTempInputFile = null;
+export const ffmpegExec = async (
+    command: string[],
+    dataOrPathOrZipItem: Uint8Array | string | ZipItem,
+    outputFileExtension: string,
+    timeoutMS: number,
+): Promise<Uint8Array> => {
+    // 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.
+
+    const {
+        path: inputFilePath,
+        isFileTemporary: isInputFileTemporary,
+        writeToTemporaryFile: writeToTemporaryInputFile,
+    } = await makeFileForDataOrPathOrZipItem(dataOrPathOrZipItem);
+
+    const outputFilePath = await makeTempFilePath(outputFileExtension);
     try {
     try {
-        if (!existsSync(inputFile.path)) {
-            const tempFilePath = await generateTempFilePath(inputFile.name);
-            await writeStream(tempFilePath, await inputFile.stream());
-            inputFilePath = tempFilePath;
-            createdTempInputFile = true;
-        } else {
-            inputFilePath = inputFile.path;
-        }
-        const outputFileData = await runFFmpegCmd_(
-            cmd,
+        await writeToTemporaryInputFile();
+
+        const cmd = substitutePlaceholders(
+            command,
             inputFilePath,
             inputFilePath,
-            outputFileName,
-            dontTimeout,
+            outputFilePath,
         );
         );
-        return new File([outputFileData], outputFileName);
+
+        if (timeoutMS) await withTimeout(execAsync(cmd), 30 * 1000);
+        else await execAsync(cmd);
+
+        return fs.readFile(outputFilePath);
     } finally {
     } finally {
-        if (createdTempInputFile) {
-            await deleteTempFile(inputFilePath);
+        try {
+            if (isInputFileTemporary) await deleteTempFile(inputFilePath);
+            await deleteTempFile(outputFilePath);
+        } catch (e) {
+            log.error("Could not clean up temp files", e);
         }
         }
     }
     }
-}
+};
 
 
-export async function runFFmpegCmd_(
-    cmd: string[],
+const substitutePlaceholders = (
+    command: string[],
     inputFilePath: string,
     inputFilePath: string,
-    outputFileName: string,
-    dontTimeout = false,
-) {
-    let tempOutputFilePath: string;
-    try {
-        tempOutputFilePath = await generateTempFilePath(outputFileName);
-
-        cmd = cmd.map((cmdPart) => {
-            if (cmdPart === FFMPEG_PLACEHOLDER) {
-                return ffmpegBinaryPath();
-            } else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
-                return inputFilePath;
-            } else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
-                return tempOutputFilePath;
-            } else {
-                return cmdPart;
-            }
-        });
-
-        if (dontTimeout) {
-            await execAsync(cmd);
+    outputFilePath: string,
+) =>
+    command.map((segment) => {
+        if (segment == ffmpegPathPlaceholder) {
+            return ffmpegBinaryPath();
+        } else if (segment == inputPathPlaceholder) {
+            return inputFilePath;
+        } else if (segment == outputPathPlaceholder) {
+            return outputFilePath;
         } else {
         } else {
-            await promiseWithTimeout(execAsync(cmd), 30 * 1000);
+            return segment;
         }
         }
-
-        if (!existsSync(tempOutputFilePath)) {
-            throw new Error("ffmpeg output file not found");
-        }
-        const outputFile = await fs.readFile(tempOutputFilePath);
-        return new Uint8Array(outputFile);
-    } catch (e) {
-        log.error("FFMPEG command failed", e);
-        throw e;
-    } finally {
-        await deleteTempFile(tempOutputFilePath);
-    }
-}
+    });
 
 
 /**
 /**
  * Return the path to the `ffmpeg` binary.
  * Return the path to the `ffmpeg` binary.
  *
  *
- * At runtime, the ffmpeg binary is present in a path like (macOS example):
+ * At runtime, the FFmpeg binary is present in a path like (macOS example):
  * `ente.app/Contents/Resources/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg`
  * `ente.app/Contents/Resources/app.asar.unpacked/node_modules/ffmpeg-static/ffmpeg`
  */
  */
 const ffmpegBinaryPath = () => {
 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");
-};
-
-export async function writeTempFile(fileStream: Uint8Array, fileName: string) {
-    const tempFilePath = await generateTempFilePath(fileName);
-    await fs.writeFile(tempFilePath, fileStream);
-    return tempFilePath;
-}
-
-export async function deleteTempFile(tempFilePath: string) {
-    const tempDirPath = await getTempDirPath();
-    if (!tempFilePath.startsWith(tempDirPath))
-        log.error("Attempting to delete a non-temp file ${tempFilePath}");
-    await fs.rm(tempFilePath, { force: true });
-}
-
-const promiseWithTimeout = async <T>(
-    request: Promise<T>,
-    timeout: number,
-): Promise<T> => {
-    const timeoutRef: {
-        current: NodeJS.Timeout;
-    } = { current: null };
-    const rejectOnTimeout = new Promise<null>((_, reject) => {
-        timeoutRef.current = setTimeout(
-            () => reject(new Error("Operation timed out")),
-            timeout,
-        );
-    });
-    const requestWithTimeOutCancellation = async () => {
-        const resp = await request;
-        clearTimeout(timeoutRef.current);
-        return resp;
-    };
-    return await Promise.race([
-        requestWithTimeOutCancellation(),
-        rejectOnTimeout,
-    ]);
+    return ensure(pathToFfmpeg).replace("app.asar", "app.asar.unpacked");
 };
 };

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

@@ -1,190 +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);
 
 
-    for (const filePath of filePaths) {
-        const absolute = path.join(dirPath, filePath);
-        files = [...files, ...(await getDirFilePaths(absolute))];
-    }
+export const fsExists = (path: string) => existsSync(path);
 
 
-    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 getValidPaths = (paths: string[]) => {
-    if (!paths) {
-        return [] as string[];
-    }
-    return paths.filter(async (path) => {
-        try {
-            await fs.stat(path).then((stat) => stat.isFile());
-        } catch (e) {
-            return false;
-        }
-    });
-};
+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();
 };
 };

+ 159 - 0
desktop/src/main/services/image.ts

@@ -0,0 +1,159 @@
+/** @file Image format conversions and thumbnail generation */
+
+import fs from "node:fs/promises";
+import path from "node:path";
+import { CustomErrorMessage, type ZipItem } from "../../types/ipc";
+import log from "../log";
+import { execAsync, isDev } from "../utils/electron";
+import {
+    deleteTempFile,
+    makeFileForDataOrPathOrZipItem,
+    makeTempFilePath,
+} from "../utils/temp";
+
+export const convertToJPEG = async (imageData: Uint8Array) => {
+    const inputFilePath = await makeTempFilePath();
+    const outputFilePath = await makeTempFilePath("jpeg");
+
+    // Construct the command first, it may throw NotAvailable on win32.
+    const command = convertToJPEGCommand(inputFilePath, outputFilePath);
+
+    try {
+        await fs.writeFile(inputFilePath, imageData);
+        await execAsync(command);
+        return new Uint8Array(await fs.readFile(outputFilePath));
+    } finally {
+        try {
+            await deleteTempFile(inputFilePath);
+            await deleteTempFile(outputFilePath);
+        } catch (e) {
+            log.error("Could not clean up temp files", e);
+        }
+    }
+};
+
+const convertToJPEGCommand = (
+    inputFilePath: string,
+    outputFilePath: string,
+) => {
+    switch (process.platform) {
+        case "darwin":
+            return [
+                "sips",
+                "-s",
+                "format",
+                "jpeg",
+                inputFilePath,
+                "--out",
+                outputFilePath,
+            ];
+
+        case "linux":
+            return [
+                imageMagickPath(),
+                inputFilePath,
+                "-quality",
+                "100%",
+                outputFilePath,
+            ];
+
+        default: // "win32"
+            throw new Error(CustomErrorMessage.NotAvailable);
+    }
+};
+
+/** Path to the Linux image-magick executable bundled with our app */
+const imageMagickPath = () =>
+    path.join(isDev ? "build" : process.resourcesPath, "image-magick");
+
+export const generateImageThumbnail = async (
+    dataOrPathOrZipItem: Uint8Array | string | ZipItem,
+    maxDimension: number,
+    maxSize: number,
+): Promise<Uint8Array> => {
+    const {
+        path: inputFilePath,
+        isFileTemporary: isInputFileTemporary,
+        writeToTemporaryFile: writeToTemporaryInputFile,
+    } = await makeFileForDataOrPathOrZipItem(dataOrPathOrZipItem);
+
+    const outputFilePath = await makeTempFilePath("jpeg");
+
+    // Construct the command first, it may throw `NotAvailable` on win32.
+    let quality = 70;
+    let command = generateImageThumbnailCommand(
+        inputFilePath,
+        outputFilePath,
+        maxDimension,
+        quality,
+    );
+
+    try {
+        await writeToTemporaryInputFile();
+
+        let thumbnail: Uint8Array;
+        do {
+            await execAsync(command);
+            thumbnail = new Uint8Array(await fs.readFile(outputFilePath));
+            quality -= 10;
+            command = generateImageThumbnailCommand(
+                inputFilePath,
+                outputFilePath,
+                maxDimension,
+                quality,
+            );
+        } while (thumbnail.length > maxSize && quality > 50);
+        return thumbnail;
+    } finally {
+        try {
+            if (isInputFileTemporary) await deleteTempFile(inputFilePath);
+            await deleteTempFile(outputFilePath);
+        } catch (e) {
+            log.error("Could not clean up temp files", e);
+        }
+    }
+};
+
+const generateImageThumbnailCommand = (
+    inputFilePath: string,
+    outputFilePath: string,
+    maxDimension: number,
+    quality: number,
+) => {
+    switch (process.platform) {
+        case "darwin":
+            return [
+                "sips",
+                "-s",
+                "format",
+                "jpeg",
+                "-s",
+                "formatOptions",
+                `${quality}`,
+                "-Z",
+                `${maxDimension}`,
+                inputFilePath,
+                "--out",
+                outputFilePath,
+            ];
+
+        case "linux":
+            return [
+                imageMagickPath(),
+                inputFilePath,
+                "-auto-orient",
+                "-define",
+                `jpeg:size=${2 * maxDimension}x${2 * maxDimension}`,
+                "-thumbnail",
+                `${maxDimension}x${maxDimension}>`,
+                "-unsharp",
+                "0x.5",
+                "-quality",
+                `${quality}`,
+                outputFilePath,
+            ];
+
+        default: // "win32"
+            throw new Error(CustomErrorMessage.NotAvailable);
+    }
+};

+ 0 - 294
desktop/src/main/services/imageProcessor.ts

@@ -1,294 +0,0 @@
-import { existsSync } from "fs";
-import fs from "node:fs/promises";
-import path from "path";
-import { CustomErrors, ElectronFile } from "../../types/ipc";
-import log from "../log";
-import { isPlatform } from "../platform";
-import { writeStream } from "../stream";
-import { generateTempFilePath } from "../temp";
-import { execAsync, isDev } from "../util";
-import { deleteTempFile } from "./ffmpeg";
-
-const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK";
-const MAX_DIMENSION_PLACEHOLDER = "MAX_DIMENSION";
-const SAMPLE_SIZE_PLACEHOLDER = "SAMPLE_SIZE";
-const INPUT_PATH_PLACEHOLDER = "INPUT";
-const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
-const QUALITY_PLACEHOLDER = "QUALITY";
-
-const MAX_QUALITY = 70;
-const MIN_QUALITY = 50;
-
-const SIPS_HEIC_CONVERT_COMMAND_TEMPLATE = [
-    "sips",
-    "-s",
-    "format",
-    "jpeg",
-    INPUT_PATH_PLACEHOLDER,
-    "--out",
-    OUTPUT_PATH_PLACEHOLDER,
-];
-
-const SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
-    "sips",
-    "-s",
-    "format",
-    "jpeg",
-    "-s",
-    "formatOptions",
-    QUALITY_PLACEHOLDER,
-    "-Z",
-    MAX_DIMENSION_PLACEHOLDER,
-    INPUT_PATH_PLACEHOLDER,
-    "--out",
-    OUTPUT_PATH_PLACEHOLDER,
-];
-
-const IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE = [
-    IMAGE_MAGICK_PLACEHOLDER,
-    INPUT_PATH_PLACEHOLDER,
-    "-quality",
-    "100%",
-    OUTPUT_PATH_PLACEHOLDER,
-];
-
-const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
-    IMAGE_MAGICK_PLACEHOLDER,
-    INPUT_PATH_PLACEHOLDER,
-    "-auto-orient",
-    "-define",
-    `jpeg:size=${SAMPLE_SIZE_PLACEHOLDER}x${SAMPLE_SIZE_PLACEHOLDER}`,
-    "-thumbnail",
-    `${MAX_DIMENSION_PLACEHOLDER}x${MAX_DIMENSION_PLACEHOLDER}>`,
-    "-unsharp",
-    "0x.5",
-    "-quality",
-    QUALITY_PLACEHOLDER,
-    OUTPUT_PATH_PLACEHOLDER,
-];
-
-function getImageMagickStaticPath() {
-    return isDev
-        ? "resources/image-magick"
-        : path.join(process.resourcesPath, "image-magick");
-}
-
-export async function convertToJPEG(
-    fileData: Uint8Array,
-    filename: string,
-): Promise<Uint8Array> {
-    if (isPlatform("windows")) {
-        throw Error(CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED);
-    }
-    const convertedFileData = await convertToJPEG_(fileData, filename);
-    return convertedFileData;
-}
-
-async function convertToJPEG_(
-    fileData: Uint8Array,
-    filename: string,
-): Promise<Uint8Array> {
-    let tempInputFilePath: string;
-    let tempOutputFilePath: string;
-    try {
-        tempInputFilePath = await generateTempFilePath(filename);
-        tempOutputFilePath = await generateTempFilePath("output.jpeg");
-
-        await fs.writeFile(tempInputFilePath, fileData);
-
-        await execAsync(
-            constructConvertCommand(tempInputFilePath, tempOutputFilePath),
-        );
-
-        return new Uint8Array(await fs.readFile(tempOutputFilePath));
-    } catch (e) {
-        log.error("Failed to convert HEIC", e);
-        throw e;
-    } finally {
-        try {
-            await fs.rm(tempInputFilePath, { force: true });
-        } catch (e) {
-            log.error(`Failed to remove tempInputFile ${tempInputFilePath}`, e);
-        }
-        try {
-            await fs.rm(tempOutputFilePath, { force: true });
-        } catch (e) {
-            log.error(
-                `Failed to remove tempOutputFile ${tempOutputFilePath}`,
-                e,
-            );
-        }
-    }
-}
-
-function constructConvertCommand(
-    tempInputFilePath: string,
-    tempOutputFilePath: string,
-) {
-    let convertCmd: string[];
-    if (isPlatform("mac")) {
-        convertCmd = SIPS_HEIC_CONVERT_COMMAND_TEMPLATE.map((cmdPart) => {
-            if (cmdPart === INPUT_PATH_PLACEHOLDER) {
-                return tempInputFilePath;
-            }
-            if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
-                return tempOutputFilePath;
-            }
-            return cmdPart;
-        });
-    } else if (isPlatform("linux")) {
-        convertCmd = IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE.map(
-            (cmdPart) => {
-                if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
-                    return getImageMagickStaticPath();
-                }
-                if (cmdPart === INPUT_PATH_PLACEHOLDER) {
-                    return tempInputFilePath;
-                }
-                if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
-                    return tempOutputFilePath;
-                }
-                return cmdPart;
-            },
-        );
-    } else {
-        throw new Error(`Unsupported OS ${process.platform}`);
-    }
-    return convertCmd;
-}
-
-export async function generateImageThumbnail(
-    inputFile: File | ElectronFile,
-    maxDimension: number,
-    maxSize: number,
-): Promise<Uint8Array> {
-    let inputFilePath = null;
-    let createdTempInputFile = null;
-    try {
-        if (isPlatform("windows")) {
-            throw Error(
-                CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED,
-            );
-        }
-        if (!existsSync(inputFile.path)) {
-            const tempFilePath = await generateTempFilePath(inputFile.name);
-            await writeStream(tempFilePath, await inputFile.stream());
-            inputFilePath = tempFilePath;
-            createdTempInputFile = true;
-        } else {
-            inputFilePath = inputFile.path;
-        }
-        const thumbnail = await generateImageThumbnail_(
-            inputFilePath,
-            maxDimension,
-            maxSize,
-        );
-        return thumbnail;
-    } finally {
-        if (createdTempInputFile) {
-            try {
-                await deleteTempFile(inputFilePath);
-            } catch (e) {
-                log.error(`Failed to deleteTempFile ${inputFilePath}`, e);
-            }
-        }
-    }
-}
-
-async function generateImageThumbnail_(
-    inputFilePath: string,
-    width: number,
-    maxSize: number,
-): Promise<Uint8Array> {
-    let tempOutputFilePath: string;
-    let quality = MAX_QUALITY;
-    try {
-        tempOutputFilePath = await generateTempFilePath("thumb.jpeg");
-        let thumbnail: Uint8Array;
-        do {
-            await execAsync(
-                constructThumbnailGenerationCommand(
-                    inputFilePath,
-                    tempOutputFilePath,
-                    width,
-                    quality,
-                ),
-            );
-            thumbnail = new Uint8Array(await fs.readFile(tempOutputFilePath));
-            quality -= 10;
-        } while (thumbnail.length > maxSize && quality > MIN_QUALITY);
-        return thumbnail;
-    } catch (e) {
-        log.error("Failed to generate image thumbnail", e);
-        throw e;
-    } finally {
-        try {
-            await fs.rm(tempOutputFilePath, { force: true });
-        } catch (e) {
-            log.error(
-                `Failed to remove tempOutputFile ${tempOutputFilePath}`,
-                e,
-            );
-        }
-    }
-}
-
-function constructThumbnailGenerationCommand(
-    inputFilePath: string,
-    tempOutputFilePath: string,
-    maxDimension: number,
-    quality: number,
-) {
-    let thumbnailGenerationCmd: string[];
-    if (isPlatform("mac")) {
-        thumbnailGenerationCmd = SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map(
-            (cmdPart) => {
-                if (cmdPart === INPUT_PATH_PLACEHOLDER) {
-                    return inputFilePath;
-                }
-                if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
-                    return tempOutputFilePath;
-                }
-                if (cmdPart === MAX_DIMENSION_PLACEHOLDER) {
-                    return maxDimension.toString();
-                }
-                if (cmdPart === QUALITY_PLACEHOLDER) {
-                    return quality.toString();
-                }
-                return cmdPart;
-            },
-        );
-    } else if (isPlatform("linux")) {
-        thumbnailGenerationCmd =
-            IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map((cmdPart) => {
-                if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
-                    return getImageMagickStaticPath();
-                }
-                if (cmdPart === INPUT_PATH_PLACEHOLDER) {
-                    return inputFilePath;
-                }
-                if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
-                    return tempOutputFilePath;
-                }
-                if (cmdPart.includes(SAMPLE_SIZE_PLACEHOLDER)) {
-                    return cmdPart.replaceAll(
-                        SAMPLE_SIZE_PLACEHOLDER,
-                        (2 * maxDimension).toString(),
-                    );
-                }
-                if (cmdPart.includes(MAX_DIMENSION_PLACEHOLDER)) {
-                    return cmdPart.replaceAll(
-                        MAX_DIMENSION_PLACEHOLDER,
-                        maxDimension.toString(),
-                    );
-                }
-                if (cmdPart === QUALITY_PLACEHOLDER) {
-                    return quality.toString();
-                }
-                return cmdPart;
-            });
-    } else {
-        throw new Error(`Unsupported OS ${process.platform}`);
-    }
-    return thumbnailGenerationCmd;
-}

+ 75 - 151
desktop/src/main/services/ml-clip.ts

@@ -5,117 +5,25 @@
  *
  *
  * @see `web/apps/photos/src/services/clip-service.ts` for more details.
  * @see `web/apps/photos/src/services/clip-service.ts` for more details.
  */
  */
-import { existsSync } from "fs";
 import jpeg from "jpeg-js";
 import jpeg from "jpeg-js";
 import fs from "node:fs/promises";
 import fs from "node:fs/promises";
 import * as ort from "onnxruntime-node";
 import * as ort from "onnxruntime-node";
 import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
 import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
-import { CustomErrors } from "../../types/ipc";
 import log from "../log";
 import log from "../log";
 import { writeStream } from "../stream";
 import { writeStream } from "../stream";
-import { generateTempFilePath } from "../temp";
-import { deleteTempFile } from "./ffmpeg";
-import {
-    createInferenceSession,
-    downloadModel,
-    modelPathDownloadingIfNeeded,
-    modelSavePath,
-} from "./ml";
+import { ensure } from "../utils/common";
+import { deleteTempFile, makeTempFilePath } from "../utils/temp";
+import { makeCachedInferenceSession } from "./ml";
 
 
-const textModelName = "clip-text-vit-32-uint8.onnx";
-const textModelByteSize = 64173509; // 61.2 MB
-
-const imageModelName = "clip-image-vit-32-float32.onnx";
-const imageModelByteSize = 351468764; // 335.2 MB
-
-let activeImageModelDownload: Promise<string> | undefined;
-
-const imageModelPathDownloadingIfNeeded = async () => {
-    try {
-        if (activeImageModelDownload) {
-            log.info("Waiting for CLIP image model download to finish");
-            await activeImageModelDownload;
-        } else {
-            activeImageModelDownload = modelPathDownloadingIfNeeded(
-                imageModelName,
-                imageModelByteSize,
-            );
-            return await activeImageModelDownload;
-        }
-    } finally {
-        activeImageModelDownload = undefined;
-    }
-};
-
-let textModelDownloadInProgress = false;
-
-/* TODO(MR): use the generic method. Then we can remove the exports for the
-   internal details functions that we use here */
-const textModelPathDownloadingIfNeeded = async () => {
-    if (textModelDownloadInProgress)
-        throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
-
-    const modelPath = modelSavePath(textModelName);
-    if (!existsSync(modelPath)) {
-        log.info("CLIP text model not found, downloading");
-        textModelDownloadInProgress = true;
-        downloadModel(modelPath, textModelName)
-            .catch((e) => {
-                // log but otherwise ignore
-                log.error("CLIP text model download failed", e);
-            })
-            .finally(() => {
-                textModelDownloadInProgress = false;
-            });
-        throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
-    } else {
-        const localFileSize = (await fs.stat(modelPath)).size;
-        if (localFileSize !== textModelByteSize) {
-            log.error(
-                `CLIP text model size ${localFileSize} does not match the expected size, downloading again`,
-            );
-            textModelDownloadInProgress = true;
-            downloadModel(modelPath, textModelName)
-                .catch((e) => {
-                    // log but otherwise ignore
-                    log.error("CLIP text model download failed", e);
-                })
-                .finally(() => {
-                    textModelDownloadInProgress = false;
-                });
-            throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
-        }
-    }
-
-    return modelPath;
-};
-
-let imageSessionPromise: Promise<any> | undefined;
-
-const onnxImageSession = async () => {
-    if (!imageSessionPromise) {
-        imageSessionPromise = (async () => {
-            const modelPath = await imageModelPathDownloadingIfNeeded();
-            return createInferenceSession(modelPath);
-        })();
-    }
-    return imageSessionPromise;
-};
-
-let _textSession: any = null;
-
-const onnxTextSession = async () => {
-    if (!_textSession) {
-        const modelPath = await textModelPathDownloadingIfNeeded();
-        _textSession = await createInferenceSession(modelPath);
-    }
-    return _textSession;
-};
+const cachedCLIPImageSession = makeCachedInferenceSession(
+    "clip-image-vit-32-float32.onnx",
+    351468764 /* 335.2 MB */,
+);
 
 
 export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
 export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
-    const tempFilePath = await generateTempFilePath("");
+    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 {
@@ -124,42 +32,43 @@ export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
 };
 };
 
 
 const clipImageEmbedding_ = async (jpegFilePath: string) => {
 const clipImageEmbedding_ = async (jpegFilePath: string) => {
-    const imageSession = await onnxImageSession();
+    const session = await cachedCLIPImageSession();
     const t1 = Date.now();
     const t1 = Date.now();
     const rgbData = await getRGBData(jpegFilePath);
     const rgbData = await getRGBData(jpegFilePath);
     const feeds = {
     const feeds = {
         input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
         input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
     };
     };
     const t2 = Date.now();
     const t2 = Date.now();
-    const results = await imageSession.run(feeds);
+    const results = await session.run(feeds);
     log.debug(
     log.debug(
         () =>
         () =>
             `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)`,
     );
     );
-    const imageEmbedding = results["output"].data; // Float32Array
+    /* Need these model specific casts to type the result */
+    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];
@@ -168,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);
             }
             }
         }
         }
     }
     }
@@ -211,26 +120,41 @@ 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;
 };
 };
 
 
-let _tokenizer: Tokenizer = null;
+const cachedCLIPTextSession = makeCachedInferenceSession(
+    "clip-text-vit-32-uint8.onnx",
+    64173509 /* 61.2 MB */,
+);
+
+let _tokenizer: Tokenizer | undefined;
 const getTokenizer = () => {
 const getTokenizer = () => {
-    if (!_tokenizer) {
-        _tokenizer = new Tokenizer();
-    }
+    if (!_tokenizer) _tokenizer = new Tokenizer();
     return _tokenizer;
     return _tokenizer;
 };
 };
 
 
-export const clipTextEmbedding = async (text: string) => {
-    const imageSession = await onnxTextSession();
+export const clipTextEmbeddingIfAvailable = async (text: string) => {
+    const sessionOrStatus = await Promise.race([
+        cachedCLIPTextSession(),
+        "downloading-model",
+    ]);
+
+    // Don't wait for the download to complete
+    if (typeof sessionOrStatus == "string") {
+        log.info(
+            "Ignoring CLIP text embedding request because model download is pending",
+        );
+        return undefined;
+    }
+
+    const session = sessionOrStatus;
     const t1 = Date.now();
     const t1 = Date.now();
     const tokenizer = getTokenizer();
     const tokenizer = getTokenizer();
     const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
     const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
@@ -238,11 +162,11 @@ export const clipTextEmbedding = async (text: string) => {
         input: new ort.Tensor("int32", tokenizedText, [1, 77]),
         input: new ort.Tensor("int32", tokenizedText, [1, 77]),
     };
     };
     const t2 = Date.now();
     const t2 = Date.now();
-    const results = await imageSession.run(feeds);
+    const results = await session.run(feeds);
     log.debug(
     log.debug(
         () =>
         () =>
             `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;
+    const textEmbedding = ensure(results.output).data as Float32Array;
     return normalizeEmbedding(textEmbedding);
     return normalizeEmbedding(textEmbedding);
 };
 };

+ 17 - 73
desktop/src/main/services/ml-face.ts

@@ -8,87 +8,30 @@
  */
  */
 import * as ort from "onnxruntime-node";
 import * as ort from "onnxruntime-node";
 import log from "../log";
 import log from "../log";
-import { createInferenceSession, modelPathDownloadingIfNeeded } from "./ml";
+import { ensure } from "../utils/common";
+import { makeCachedInferenceSession } from "./ml";
 
 
-const faceDetectionModelName = "yolov5s_face_640_640_dynamic.onnx";
-const faceDetectionModelByteSize = 30762872; // 29.3 MB
-
-const faceEmbeddingModelName = "mobilefacenet_opset15.onnx";
-const faceEmbeddingModelByteSize = 5286998; // 5 MB
-
-let activeFaceDetectionModelDownload: Promise<string> | undefined;
-
-const faceDetectionModelPathDownloadingIfNeeded = async () => {
-    try {
-        if (activeFaceDetectionModelDownload) {
-            log.info("Waiting for face detection model download to finish");
-            await activeFaceDetectionModelDownload;
-        } else {
-            activeFaceDetectionModelDownload = modelPathDownloadingIfNeeded(
-                faceDetectionModelName,
-                faceDetectionModelByteSize,
-            );
-            return await activeFaceDetectionModelDownload;
-        }
-    } finally {
-        activeFaceDetectionModelDownload = undefined;
-    }
-};
-
-let _faceDetectionSession: Promise<ort.InferenceSession> | undefined;
-
-const faceDetectionSession = async () => {
-    if (!_faceDetectionSession) {
-        _faceDetectionSession =
-            faceDetectionModelPathDownloadingIfNeeded().then((modelPath) =>
-                createInferenceSession(modelPath),
-            );
-    }
-    return _faceDetectionSession;
-};
-
-let activeFaceEmbeddingModelDownload: Promise<string> | undefined;
-
-const faceEmbeddingModelPathDownloadingIfNeeded = async () => {
-    try {
-        if (activeFaceEmbeddingModelDownload) {
-            log.info("Waiting for face embedding model download to finish");
-            await activeFaceEmbeddingModelDownload;
-        } else {
-            activeFaceEmbeddingModelDownload = modelPathDownloadingIfNeeded(
-                faceEmbeddingModelName,
-                faceEmbeddingModelByteSize,
-            );
-            return await activeFaceEmbeddingModelDownload;
-        }
-    } finally {
-        activeFaceEmbeddingModelDownload = undefined;
-    }
-};
-
-let _faceEmbeddingSession: Promise<ort.InferenceSession> | undefined;
-
-const faceEmbeddingSession = async () => {
-    if (!_faceEmbeddingSession) {
-        _faceEmbeddingSession =
-            faceEmbeddingModelPathDownloadingIfNeeded().then((modelPath) =>
-                createInferenceSession(modelPath),
-            );
-    }
-    return _faceEmbeddingSession;
-};
+const cachedFaceDetectionSession = makeCachedInferenceSession(
+    "yolov5s_face_640_640_dynamic.onnx",
+    30762872 /* 29.3 MB */,
+);
 
 
 export const detectFaces = async (input: Float32Array) => {
 export const detectFaces = async (input: Float32Array) => {
-    const session = await faceDetectionSession();
+    const session = await cachedFaceDetectionSession();
     const t = Date.now();
     const t = Date.now();
     const feeds = {
     const feeds = {
         input: new ort.Tensor("float32", input, [1, 3, 640, 640]),
         input: new ort.Tensor("float32", input, [1, 3, 640, 640]),
     };
     };
     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(
+    "mobilefacenet_opset15.onnx",
+    5286998 /* 5 MB */,
+);
+
 export const faceEmbedding = async (input: Float32Array) => {
 export const faceEmbedding = async (input: Float32Array) => {
     // Dimension of each face (alias)
     // Dimension of each face (alias)
     const mobileFaceNetFaceSize = 112;
     const mobileFaceNetFaceSize = 112;
@@ -98,11 +41,12 @@ export const faceEmbedding = async (input: Float32Array) => {
     const n = Math.round(input.length / (z * z * 3));
     const n = Math.round(input.length / (z * z * 3));
     const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]);
     const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]);
 
 
-    const session = await faceEmbeddingSession();
+    const session = await cachedFaceEmbeddingSession();
     const t = Date.now();
     const t = Date.now();
     const feeds = { img_inputs: inputTensor };
     const feeds = { img_inputs: inputTensor };
     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`);
-    // TODO: What's with this type? It works in practice, but double check.
-    return (results.embeddings as unknown as any)["cpuData"]; // as Float32Array;
+    /* Need these model specific casts to extract and type the result */
+    return (results.embeddings as unknown as Record<string, unknown>)
+        .cpuData as Float32Array;
 };
 };

+ 56 - 10
desktop/src/main/services/ml.ts

@@ -1,5 +1,5 @@
 /**
 /**
- * @file AI/ML related functionality.
+ * @file AI/ML related functionality, generic layer.
  *
  *
  * @see also `ml-clip.ts`, `ml-face.ts`.
  * @see also `ml-clip.ts`, `ml-face.ts`.
  *
  *
@@ -18,6 +18,50 @@ import * as ort from "onnxruntime-node";
 import log from "../log";
 import log from "../log";
 import { writeStream } from "../stream";
 import { writeStream } from "../stream";
 
 
+/**
+ * Return a function that can be used to trigger a download of the specified
+ * model, and the creating of an ONNX inference session initialized using it.
+ *
+ * Multiple parallel calls to the returned function are fine, it ensures that
+ * the the model will be downloaded and the session created using it only once.
+ * All pending calls to it meanwhile will just await on the same promise.
+ *
+ * And once the promise is resolved, the create ONNX inference session will be
+ * cached, so subsequent calls to the returned function will just reuse the same
+ * session.
+ *
+ * {@link makeCachedInferenceSession} can itself be called anytime, it doesn't
+ * actively trigger a download until the returned function is called.
+ *
+ * @param modelName The name of the model to download.
+ *
+ * @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
+ * will redownload it.
+ *
+ * @returns a function. calling that function returns a promise to an ONNX
+ * session.
+ */
+export const makeCachedInferenceSession = (
+    modelName: string,
+    modelByteSize: number,
+) => {
+    let session: Promise<ort.InferenceSession> | undefined;
+
+    const download = () =>
+        modelPathDownloadingIfNeeded(modelName, modelByteSize);
+
+    const createSession = (modelPath: string) =>
+        createInferenceSession(modelPath);
+
+    const cachedInferenceSession = () => {
+        if (!session) session = download().then(createSession);
+        return session;
+    };
+
+    return cachedInferenceSession;
+};
+
 /**
 /**
  * Download the model named {@link modelName} if we don't already have it.
  * Download the model named {@link modelName} if we don't already have it.
  *
  *
@@ -26,7 +70,7 @@ import { writeStream } from "../stream";
  *
  *
  * @returns the path to the model on the local machine.
  * @returns the path to the model on the local machine.
  */
  */
-export const modelPathDownloadingIfNeeded = async (
+const modelPathDownloadingIfNeeded = async (
     modelName: string,
     modelName: string,
     expectedByteSize: number,
     expectedByteSize: number,
 ) => {
 ) => {
@@ -49,31 +93,33 @@ export const modelPathDownloadingIfNeeded = async (
 };
 };
 
 
 /** Return the path where the given {@link modelName} is meant to be saved */
 /** Return the path where the given {@link modelName} is meant to be saved */
-export const modelSavePath = (modelName: string) =>
+const modelSavePath = (modelName: string) =>
     path.join(app.getPath("userData"), "models", modelName);
     path.join(app.getPath("userData"), "models", modelName);
 
 
-export const downloadModel = async (saveLocation: string, name: string) => {
+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}`);
 };
 };
 
 
 /**
 /**
  * Crete an ONNX {@link InferenceSession} with some defaults.
  * Crete an ONNX {@link InferenceSession} with some defaults.
  */
  */
-export 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,
     });
     });
 };
 };

+ 22 - 10
desktop/src/main/services/store.ts

@@ -1,25 +1,37 @@
 import { safeStorage } from "electron/main";
 import { safeStorage } from "electron/main";
-import { keysStore } from "../stores/keys.store";
-import { safeStorageStore } from "../stores/safeStorage.store";
-import { uploadStatusStore } from "../stores/upload.store";
-import { watchStore } from "../stores/watch.store";
+import { safeStorageStore } from "../stores/safe-storage";
+import { uploadStatusStore } from "../stores/upload-status";
+import { watchStore } from "../stores/watch";
 
 
+/**
+ * Clear all stores except user preferences.
+ *
+ * This is useful to reset state when the user logs out.
+ */
 export const clearStores = () => {
 export const clearStores = () => {
-    uploadStatusStore.clear();
-    keysStore.clear();
     safeStorageStore.clear();
     safeStorageStore.clear();
+    uploadStatusStore.clear();
     watchStore.clear();
     watchStore.clear();
 };
 };
 
 
-export const saveEncryptionKey = async (encryptionKey: string) => {
-    const encryptedKey: Buffer = await safeStorage.encryptString(encryptionKey);
+/**
+ * [Note: Safe storage keys]
+ *
+ * On macOS, `safeStorage` stores our data under a Keychain entry named
+ * "<app-name> Safe Storage". Which resolves to:
+ *
+ * - Electron Safe Storage (dev)
+ * - ente Safe Storage    (prod)
+ */
+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 - 88
desktop/src/main/services/upload.ts

@@ -1,107 +1,149 @@
 import StreamZip from "node-stream-zip";
 import StreamZip from "node-stream-zip";
-import path from "path";
-import { ElectronFile, FILE_PATH_TYPE } from "../../types/ipc";
-import { FILE_PATH_KEYS } from "../../types/main";
-import { uploadStatusStore } from "../stores/upload.store";
-import { getElectronFile, getValidPaths, getZipFileStream } from "./fs";
-
-export const getPendingUploads = async () => {
-    const filePaths = getSavedFilePaths(FILE_PATH_TYPE.FILES);
-    const zipPaths = getSavedFilePaths(FILE_PATH_TYPE.ZIPS);
-    const collectionName = uploadStatusStore.get("collectionName");
-
-    let files: ElectronFile[] = [];
-    let type: FILE_PATH_TYPE;
-    if (zipPaths.length) {
-        type = FILE_PATH_TYPE.ZIPS;
-        for (const zipPath of zipPaths) {
-            files = [
-                ...files,
-                ...(await getElectronFilesFromGoogleZip(zipPath)),
-            ];
+import fs from "node:fs/promises";
+import path from "node:path";
+import { existsSync } from "original-fs";
+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 = FILE_PATH_TYPE.FILES;
-        files = await Promise.all(filePaths.map(getElectronFile));
     }
     }
-    return {
-        files,
-        collectionName,
-        type,
-    };
-};
 
 
-export const getSavedFilePaths = (type: FILE_PATH_TYPE) => {
-    const paths =
-        getValidPaths(
-            uploadStatusStore.get(FILE_PATH_KEYS[type]) as string[],
-        ) ?? [];
+    await zip.close();
 
 
-    setToUploadFiles(type, paths);
-    return paths;
+    return entryNames.map((entryName) => [zipPath, entryName]);
 };
 };
 
 
-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 setToUploadFiles = (type: FILE_PATH_TYPE, filePaths: string[]) => {
-    const key = FILE_PATH_KEYS[type];
-    if (filePaths) {
-        uploadStatusStore.set(key, filePaths);
+export const pathOrZipItemSize = async (
+    pathOrZipItem: string | ZipItem,
+): Promise<number> => {
+    if (typeof pathOrZipItem == "string") {
+        const stat = await fs.stat(pathOrZipItem);
+        return stat.size;
     } else {
     } else {
-        uploadStatusStore.delete(key);
+        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 setToUploadCollection = (collectionName: string) => {
-    if (collectionName) {
-        uploadStatusStore.set("collectionName", collectionName);
+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 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 {
     } else {
-        uploadStatusStore.delete("collectionName");
+        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 const clearPendingUploads = () => uploadStatusStore.clear();

+ 132 - 77
desktop/src/main/services/watch.ts

@@ -1,101 +1,156 @@
-import type { FSWatcher } from "chokidar";
-import ElectronLog from "electron-log";
-import { FolderWatch, WatchStoreType } from "../../types/ipc";
-import { watchStore } from "../stores/watch.store";
+import chokidar, { type FSWatcher } from "chokidar";
+import { BrowserWindow } from "electron/main";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { FolderWatch, type CollectionMapping } from "../../types/ipc";
+import log from "../log";
+import { watchStore } from "../stores/watch";
+import { posixPath } from "../utils/electron";
+import { fsIsDir } from "./fs";
+
+/**
+ * Create and return a new file system watcher.
+ *
+ * Internally this uses the watcher from the chokidar package.
+ *
+ * @param mainWindow The window handle is used to notify the renderer process of
+ * pertinent file system events.
+ */
+export const createWatcher = (mainWindow: BrowserWindow) => {
+    const send = (eventName: string) => (path: string) =>
+        mainWindow.webContents.send(eventName, ...eventData(path));
+
+    const folderPaths = folderWatches().map((watch) => watch.folderPath);
+
+    const watcher = chokidar.watch(folderPaths, {
+        awaitWriteFinish: true,
+    });
 
 
-export const addWatchMapping = async (
-    watcher: FSWatcher,
-    rootFolderName: string,
-    folderPath: string,
-    uploadStrategy: number,
-) => {
-    ElectronLog.log(`Adding watch mapping: ${folderPath}`);
-    const watchMappings = getWatchMappings();
-    if (isMappingPresent(watchMappings, folderPath)) {
-        throw new Error(`Watch mapping already exists`);
-    }
+    watcher
+        .on("add", send("watchAddFile"))
+        .on("unlink", send("watchRemoveFile"))
+        .on("unlinkDir", send("watchRemoveDir"))
+        .on("error", (error) => log.error("Error while watching files", error));
 
 
-    watcher.add(folderPath);
+    return watcher;
+};
 
 
-    watchMappings.push({
-        rootFolderName,
-        uploadStrategy,
-        folderPath,
-        syncedFiles: [],
-        ignoredFiles: [],
-    });
+const eventData = (platformPath: string): [string, FolderWatch] => {
+    const path = posixPath(platformPath);
 
 
-    setWatchMappings(watchMappings);
+    const watch = folderWatches().find((watch) =>
+        path.startsWith(watch.folderPath + "/"),
+    );
+
+    if (!watch) throw new Error(`No folder watch was found for path ${path}`);
+
+    return [path, watch];
 };
 };
 
 
-function isMappingPresent(watchMappings: FolderWatch[], folderPath: string) {
-    const watchMapping = watchMappings?.find(
-        (mapping) => mapping.folderPath === folderPath,
-    );
-    return !!watchMapping;
-}
+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);
+    }
+    return valid;
+};
+
+const folderWatches = (): FolderWatch[] => watchStore.get("mappings") ?? [];
+
+const setFolderWatches = (watches: FolderWatch[]) =>
+    watchStore.set("mappings", watches);
 
 
-export const removeWatchMapping = async (
+export const watchAdd = async (
     watcher: FSWatcher,
     watcher: FSWatcher,
     folderPath: string,
     folderPath: string,
+    collectionMapping: CollectionMapping,
 ) => {
 ) => {
-    let watchMappings = getWatchMappings();
-    const watchMapping = watchMappings.find(
-        (mapping) => mapping.folderPath === folderPath,
-    );
+    const watches = folderWatches();
 
 
-    if (!watchMapping) {
-        throw new Error(`Watch mapping does not exist`);
-    }
+    if (!(await fsIsDir(folderPath)))
+        throw new Error(
+            `Attempting to add a folder watch for a folder path ${folderPath} that is not an existing directory`,
+        );
 
 
-    watcher.unwatch(watchMapping.folderPath);
+    if (watches.find((watch) => watch.folderPath == folderPath))
+        throw new Error(
+            `A folder watch with the given folder path ${folderPath} already exists`,
+        );
 
 
-    watchMappings = watchMappings.filter(
-        (mapping) => mapping.folderPath !== watchMapping.folderPath,
-    );
+    watches.push({
+        folderPath,
+        collectionMapping,
+        syncedFiles: [],
+        ignoredFiles: [],
+    });
+
+    setFolderWatches(watches);
 
 
-    setWatchMappings(watchMappings);
+    watcher.add(folderPath);
+
+    return watches;
 };
 };
 
 
-export function updateWatchMappingSyncedFiles(
+export const watchRemove = (watcher: FSWatcher, folderPath: string) => {
+    const watches = folderWatches();
+    const filtered = watches.filter((watch) => watch.folderPath != folderPath);
+    if (watches.length == filtered.length)
+        throw new Error(
+            `Attempting to remove a non-existing folder watch for folder path ${folderPath}`,
+        );
+    setFolderWatches(filtered);
+    watcher.unwatch(folderPath);
+    return filtered;
+};
+
+export const watchUpdateSyncedFiles = (
+    syncedFiles: FolderWatch["syncedFiles"],
     folderPath: string,
     folderPath: string,
-    files: FolderWatch["syncedFiles"],
-): void {
-    const watchMappings = getWatchMappings();
-    const watchMapping = watchMappings.find(
-        (mapping) => mapping.folderPath === folderPath,
+) => {
+    setFolderWatches(
+        folderWatches().map((watch) => {
+            if (watch.folderPath == folderPath) {
+                watch.syncedFiles = syncedFiles;
+            }
+            return watch;
+        }),
     );
     );
+};
 
 
-    if (!watchMapping) {
-        throw Error(`Watch mapping not found`);
-    }
-
-    watchMapping.syncedFiles = files;
-    setWatchMappings(watchMappings);
-}
-
-export function updateWatchMappingIgnoredFiles(
+export const watchUpdateIgnoredFiles = (
+    ignoredFiles: FolderWatch["ignoredFiles"],
     folderPath: string,
     folderPath: string,
-    files: FolderWatch["ignoredFiles"],
-): void {
-    const watchMappings = getWatchMappings();
-    const watchMapping = watchMappings.find(
-        (mapping) => mapping.folderPath === folderPath,
+) => {
+    setFolderWatches(
+        folderWatches().map((watch) => {
+            if (watch.folderPath == folderPath) {
+                watch.ignoredFiles = ignoredFiles;
+            }
+            return watch;
+        }),
     );
     );
+};
 
 
-    if (!watchMapping) {
-        throw Error(`Watch mapping not found`);
+export const watchFindFiles = async (dirPath: string) => {
+    const items = await fs.readdir(dirPath, { withFileTypes: true });
+    let paths: string[] = [];
+    for (const item of items) {
+        const itemPath = path.posix.join(dirPath, item.name);
+        if (item.isFile()) {
+            paths.push(itemPath);
+        } else if (item.isDirectory()) {
+            paths = [...paths, ...(await watchFindFiles(itemPath))];
+        }
     }
     }
+    return paths;
+};
 
 
-    watchMapping.ignoredFiles = files;
-    setWatchMappings(watchMappings);
-}
-
-export function getWatchMappings() {
-    const mappings = watchStore.get("mappings") ?? [];
-    return mappings;
-}
-
-function setWatchMappings(watchMappings: WatchStoreType["mappings"]) {
-    watchStore.set("mappings", watchMappings);
-}
+export const watchReset = (watcher: FSWatcher) => {
+    watcher.unwatch(folderWatches().map((watch) => watch.folderPath));
+};

+ 0 - 18
desktop/src/main/stores/keys.store.ts

@@ -1,18 +0,0 @@
-import Store, { Schema } from "electron-store";
-import type { KeysStoreType } from "../../types/main";
-
-const keysStoreSchema: Schema<KeysStoreType> = {
-    AnonymizeUserID: {
-        type: "object",
-        properties: {
-            id: {
-                type: "string",
-            },
-        },
-    },
-};
-
-export const keysStore = new Store({
-    name: "keys",
-    schema: keysStoreSchema,
-});

+ 5 - 2
desktop/src/main/stores/safeStorage.store.ts → desktop/src/main/stores/safe-storage.ts

@@ -1,7 +1,10 @@
 import Store, { Schema } from "electron-store";
 import Store, { Schema } from "electron-store";
-import type { SafeStorageStoreType } from "../../types/main";
 
 
-const safeStorageSchema: Schema<SafeStorageStoreType> = {
+interface SafeStorageStore {
+    encryptionKey?: string;
+}
+
+const safeStorageSchema: Schema<SafeStorageStore> = {
     encryptionKey: {
     encryptionKey: {
         type: "string",
         type: "string",
     },
     },

+ 54 - 0
desktop/src/main/stores/upload-status.ts

@@ -0,0 +1,54 @@
+import Store, { Schema } from "electron-store";
+
+export interface UploadStatusStore {
+    /**
+     * 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> = {
+    collectionName: {
+        type: "string",
+    },
+    filePaths: {
+        type: "array",
+        items: {
+            type: "string",
+        },
+    },
+    zipItems: {
+        type: "array",
+        items: {
+            type: "array",
+            items: {
+                type: "string",
+            },
+        },
+    },
+    zipPaths: {
+        type: "array",
+        items: {
+            type: "string",
+        },
+    },
+};
+
+export const uploadStatusStore = new Store({
+    name: "upload-status",
+    schema: uploadStatusSchema,
+});

+ 0 - 25
desktop/src/main/stores/upload.store.ts

@@ -1,25 +0,0 @@
-import Store, { Schema } from "electron-store";
-import type { UploadStoreType } from "../../types/main";
-
-const uploadStoreSchema: Schema<UploadStoreType> = {
-    filePaths: {
-        type: "array",
-        items: {
-            type: "string",
-        },
-    },
-    zipPaths: {
-        type: "array",
-        items: {
-            type: "string",
-        },
-    },
-    collectionName: {
-        type: "string",
-    },
-};
-
-export const uploadStatusStore = new Store({
-    name: "upload-status",
-    schema: uploadStoreSchema,
-});

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

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

+ 0 - 47
desktop/src/main/stores/watch.store.ts

@@ -1,47 +0,0 @@
-import Store, { Schema } from "electron-store";
-import { WatchStoreType } from "../../types/ipc";
-
-const watchStoreSchema: Schema<WatchStoreType> = {
-    mappings: {
-        type: "array",
-        items: {
-            type: "object",
-            properties: {
-                rootFolderName: {
-                    type: "string",
-                },
-                uploadStrategy: {
-                    type: "number",
-                },
-                folderPath: {
-                    type: "string",
-                },
-                syncedFiles: {
-                    type: "array",
-                    items: {
-                        type: "object",
-                        properties: {
-                            path: {
-                                type: "string",
-                            },
-                            id: {
-                                type: "number",
-                            },
-                        },
-                    },
-                },
-                ignoredFiles: {
-                    type: "array",
-                    items: {
-                        type: "string",
-                    },
-                },
-            },
-        },
-    },
-};
-
-export const watchStore = new Store({
-    name: "watch-status",
-    schema: watchStoreSchema,
-});

+ 77 - 0
desktop/src/main/stores/watch.ts

@@ -0,0 +1,77 @@
+import Store, { Schema } from "electron-store";
+import { type FolderWatch } from "../../types/ipc";
+import log from "../log";
+
+interface WatchStore {
+    mappings?: FolderWatchWithLegacyFields[];
+}
+
+type FolderWatchWithLegacyFields = FolderWatch & {
+    /** @deprecated Only retained for migration, do not use in other code */
+    rootFolderName?: string;
+    /** @deprecated Only retained for migration, do not use in other code */
+    uploadStrategy?: number;
+};
+
+const watchStoreSchema: Schema<WatchStore> = {
+    mappings: {
+        type: "array",
+        items: {
+            type: "object",
+            properties: {
+                rootFolderName: { type: "string" },
+                collectionMapping: { type: "string" },
+                uploadStrategy: { type: "number" },
+                folderPath: { type: "string" },
+                syncedFiles: {
+                    type: "array",
+                    items: {
+                        type: "object",
+                        properties: {
+                            path: { type: "string" },
+                            uploadedFileID: { type: "number" },
+                            collectionID: { type: "number" },
+                        },
+                    },
+                },
+                ignoredFiles: {
+                    type: "array",
+                    items: { type: "string" },
+                },
+            },
+        },
+    },
+};
+
+export const watchStore = new Store({
+    name: "watch-status",
+    schema: watchStoreSchema,
+});
+
+/**
+ * Previous versions of the store used to store an integer to indicate the
+ * collection mapping, migrate these to the new schema if we encounter them.
+ */
+export const migrateLegacyWatchStoreIfNeeded = () => {
+    let needsUpdate = false;
+    const updatedWatches = [];
+    for (const watch of watchStore.get("mappings") ?? []) {
+        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) {
+            collectionMapping = watch.uploadStrategy == 1 ? "parent" : "root";
+            needsUpdate = true;
+        }
+        if (watch.rootFolderName) {
+            delete watch.rootFolderName;
+            needsUpdate = true;
+        }
+        updatedWatches.push({ ...watch, collectionMapping });
+    }
+    if (needsUpdate) {
+        watchStore.set("mappings", updatedWatches);
+        log.info("Migrated legacy watch store data to new schema");
+    }
+};

+ 125 - 58
desktop/src/main/stream.ts

@@ -1,15 +1,19 @@
 /**
 /**
  * @file stream data to-from renderer using a custom protocol handler.
  * @file stream data to-from renderer using a custom protocol handler.
  */
  */
-import { 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 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
- * main process (node) and the renderer process (browser) layer.
+ * main (Node.js) and renderer (Chromium) processes.
  *
  *
  * [Note: IPC streams]
  * [Note: IPC streams]
  *
  *
@@ -17,11 +21,14 @@ import log from "./log";
  * across IPC. And passing the entire contents of the file is not feasible for
  * across IPC. And passing the entire contents of the file is not feasible for
  * large video files because of the memory pressure the copying would entail.
  * large video files because of the memory pressure the copying would entail.
  *
  *
- * As an alternative, we register a custom protocol handler that can provided a
+ * As an alternative, we register a custom protocol handler that can provides a
  * bi-directional stream. The renderer can stream data to the node side by
  * bi-directional stream. The renderer can stream data to the node side by
  * streaming the request. The node side can stream to the renderer side by
  * streaming the request. The node side can stream to the renderer side by
  * streaming the response.
  * streaming the response.
  *
  *
+ * The stream is not full duplex - while both reads and writes can be streamed,
+ * they need to be streamed separately.
+ *
  * See also: [Note: Transferring large amount of data over IPC]
  * See also: [Note: Transferring large amount of data over IPC]
  *
  *
  * Depends on {@link registerPrivilegedSchemes}.
  * Depends on {@link registerPrivilegedSchemes}.
@@ -29,88 +36,148 @@ import log from "./log";
 export const registerStreamProtocol = () => {
 export const registerStreamProtocol = () => {
     protocol.handle("stream", async (request: Request) => {
     protocol.handle("stream", async (request: Request) => {
         const url = request.url;
         const url = request.url;
-        const { host, pathname } = new URL(url);
-        // Convert e.g. "%20" to spaces.
-        const path = decodeURIComponent(pathname);
+        // The request URL contains the command to run as the host, and the
+        // pathname of the file(s) as the search params.
+        const { host, searchParams } = new URL(url);
         switch (host) {
         switch (host) {
-            /* stream://write/path/to/file */
-            /*          host-pathname----- */
+            case "read":
+                return handleRead(ensure(searchParams.get("path")));
+            case "read-zip":
+                return handleReadZip(
+                    ensure(searchParams.get("zipPath")),
+                    ensure(searchParams.get("entryName")),
+                );
             case "write":
             case "write":
-                try {
-                    await writeStream(path, request.body);
-                    return new Response("", { status: 200 });
-                } catch (e) {
-                    log.error(`Failed to write stream for ${url}`, e);
-                    return new Response(
-                        `Failed to write stream: ${e.message}`,
-                        { status: 500 },
-                    );
-                }
+                return handleWrite(ensure(searchParams.get("path")), request);
             default:
             default:
                 return new Response("", { status: 404 });
                 return new Response("", { status: 404 });
         }
         }
     });
     });
 };
 };
 
 
+const handleRead = async (path: string) => {
+    try {
+        const res = await net.fetch(pathToFileURL(path).toString());
+        if (res.ok) {
+            // 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);
+
+            // Add the file's size as the Content-Length header.
+            const fileSize = stat.size;
+            res.headers.set("Content-Length", `${fileSize}`);
+
+            // Add the file's last modified time (as epoch milliseconds).
+            const mtimeMs = stat.mtimeMs;
+            res.headers.set("X-Last-Modified-Ms", `${mtimeMs}`);
+        }
+        return res;
+    } catch (e) {
+        log.error(`Failed to read stream at ${path}`, e);
+        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,
+        });
+    }
+};
+
+const handleWrite = async (path: string, request: Request) => {
+    try {
+        await writeStream(path, ensure(request.body));
+        return new Response("", { status: 200 });
+    } catch (e) {
+        log.error(`Failed to write stream to ${path}`, e);
+        return new Response(`Failed to write stream: ${String(e)}`, {
+            status: 500,
+        });
+    }
+};
+
 /**
 /**
  * Write a (web) ReadableStream to a file at the given {@link filePath}.
  * Write a (web) ReadableStream to a file at the given {@link filePath}.
  *
  *
  * 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);
         });
         });
     });
     });
 };
 };

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików