Selaa lähdekoodia

Merge remote-tracking branch 'origin/main' into beta

Prateek Sunal 1 vuosi sitten
vanhempi
commit
9e6e91fe7e
100 muutettua tiedostoa jossa 2347 lisäystä ja 2095 poistoa
  1. 2 2
      .github/workflows/auth-crowdin.yml
  2. 1 1
      .github/workflows/auth-lint.yml
  3. 1 1
      .github/workflows/docs-verify-build.yml
  4. 2 2
      .github/workflows/mobile-crowdin.yml
  5. 1 1
      .github/workflows/mobile-lint.yml
  6. 1 1
      .github/workflows/server-lint.yml
  7. 2 2
      .github/workflows/web-crowdin.yml
  8. 1 1
      .github/workflows/web-lint.yml
  9. 2 0
      auth/lib/l10n/arb/app_de.arb
  10. 0 1
      desktop/.eslintignore
  11. 0 55
      desktop/.eslintrc
  12. 31 0
      desktop/.eslintrc.js
  13. 2 1
      desktop/.gitignore
  14. 1 0
      desktop/.prettierrc.json
  15. 26 13
      desktop/CHANGELOG.md
  16. 15 8
      desktop/README.md
  17. 0 30
      desktop/build/error.html
  18. 0 24
      desktop/build/version.html
  19. 47 7
      desktop/docs/dependencies.md
  20. 42 3
      desktop/docs/dev.md
  21. 0 21
      desktop/docs/electron.md
  22. 19 19
      desktop/docs/release.md
  23. 30 0
      desktop/electron-builder.yml
  24. 10 92
      desktop/package.json
  25. 0 52
      desktop/src/api/cache.ts
  26. 0 54
      desktop/src/api/clip.ts
  27. 0 39
      desktop/src/api/common.ts
  28. 1 1
      desktop/src/api/electronStore.ts
  29. 0 23
      desktop/src/api/export.ts
  30. 1 1
      desktop/src/api/ffmpeg.ts
  31. 0 8
      desktop/src/api/fs.ts
  32. 1 1
      desktop/src/api/imageProcessor.ts
  33. 1 1
      desktop/src/api/safeStorage.ts
  34. 0 19
      desktop/src/api/system.ts
  35. 1 1
      desktop/src/api/upload.ts
  36. 94 30
      desktop/src/main.ts
  37. 42 0
      desktop/src/main/general.ts
  38. 43 0
      desktop/src/main/ipc.ts
  39. 34 0
      desktop/src/main/log.ts
  40. 409 60
      desktop/src/preload.ts
  41. 38 63
      desktop/src/services/appUpdater.ts
  42. 1 1
      desktop/src/services/chokidar.ts
  43. 14 26
      desktop/src/services/clipService.ts
  44. 0 98
      desktop/src/services/diskCache.ts
  45. 0 105
      desktop/src/services/diskLRU.ts
  46. 74 19
      desktop/src/services/ffmpeg.ts
  47. 11 89
      desktop/src/services/fs.ts
  48. 10 20
      desktop/src/services/imageProcessor.ts
  49. 0 14
      desktop/src/services/logging.ts
  50. 0 18
      desktop/src/services/sentry.ts
  51. 0 8
      desktop/src/services/userPreference.ts
  52. 0 3
      desktop/src/stores/userPreferences.store.ts
  53. 0 8
      desktop/src/types/cache.ts
  54. 12 5
      desktop/src/types/index.ts
  55. 18 5
      desktop/src/utils/clip-bpe-ts/README.md
  56. 0 27
      desktop/src/utils/common/index.ts
  57. 9 16
      desktop/src/utils/createWindow.ts
  58. 0 17
      desktop/src/utils/error.ts
  59. 15 50
      desktop/src/utils/ipcComms.ts
  60. 0 24
      desktop/src/utils/logging.ts
  61. 2 6
      desktop/src/utils/main.ts
  62. 3 6
      desktop/src/utils/menu.ts
  63. 0 295
      desktop/src/utils/processStats.ts
  64. 5 8
      desktop/src/utils/temp.ts
  65. 71 6
      desktop/tsconfig.json
  66. 229 423
      desktop/yarn.lock
  67. 76 7
      docs/docs/.vitepress/sidebar.ts
  68. 5 5
      docs/docs/auth/migration-guides/authy/index.md
  69. 76 0
      docs/docs/photos/faq/general.md
  70. 36 0
      docs/docs/photos/faq/hidden-and-archive.md
  71. 0 9
      docs/docs/photos/faq/index.md
  72. 82 0
      docs/docs/photos/faq/security-and-privacy.md
  73. 158 0
      docs/docs/photos/faq/subscription.md
  74. 57 0
      docs/docs/photos/features/background.md
  75. 22 0
      docs/docs/photos/features/backup.md
  76. 63 0
      docs/docs/photos/features/collaborate.md
  77. 59 0
      docs/docs/photos/features/deduplicate.md
  78. 7 4
      docs/docs/photos/features/family-plans.md
  79. BIN
      docs/docs/photos/features/free-up-space/free-up-space.png
  80. 18 0
      docs/docs/photos/features/free-up-space/index.md
  81. 2 2
      docs/docs/photos/features/public-link.md
  82. 3 3
      docs/docs/photos/features/quick-link.md
  83. BIN
      docs/docs/photos/features/referral-program/free-storage.png
  84. 65 0
      docs/docs/photos/features/referral-program/index.md
  85. BIN
      docs/docs/photos/features/referral-program/referral-code-application.png
  86. 0 48
      docs/docs/photos/features/referrals.md
  87. 75 0
      docs/docs/photos/features/share.md
  88. 0 41
      docs/docs/photos/features/sharing.md
  89. 0 36
      docs/docs/photos/features/watch-folder.md
  90. 68 0
      docs/docs/photos/features/watch-folders.md
  91. 6 5
      docs/docs/photos/index.md
  92. BIN
      docs/docs/photos/migration/export/export-1.png
  93. BIN
      docs/docs/photos/migration/export/export-2.png
  94. BIN
      docs/docs/photos/migration/export/export-3.png
  95. BIN
      docs/docs/photos/migration/export/export-4.png
  96. 36 0
      docs/docs/photos/migration/export/index.md
  97. 24 0
      docs/docs/photos/migration/from-amazon-photos.md
  98. BIN
      docs/docs/photos/migration/from-apple-photos/export.png
  99. 34 0
      docs/docs/photos/migration/from-apple-photos/index.md
  100. BIN
      docs/docs/photos/migration/from-apple-photos/sequential.png

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

@@ -2,12 +2,12 @@ name: "Sync Crowdin translations (auth)"
 
 on:
     push:
+        branches: [main]
         paths:
             # Run workflow when auth's intl_en.arb is changed
             - "mobile/lib/l10n/arb/app_en.arb"
             # Or the workflow itself is changed
             - ".github/workflows/auth-crowdin.yml"
-        branches: [main]
     schedule:
         # See: [Note: Run workflow on specific days of the week]
         - cron: "50 1 * * 2,5"
@@ -28,7 +28,7 @@ jobs:
                   base_path: "auth/"
                   config: "auth/crowdin.yml"
                   upload_sources: true
-                  upload_translations: true
+                  upload_translations: false
                   download_translations: true
                   localization_branch_name: crowdin-translations-auth
                   create_pull_request: true

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

@@ -3,7 +3,7 @@ name: "Lint (auth)"
 on:
     # Run on every push to a branch other than main that changes auth/
     push:
-        branches-ignore: [main]
+        branches-ignore: [main, "deploy/**"]
         paths:
             - "auth/**"
             - ".github/workflows/auth-lint.yml"

+ 1 - 1
.github/workflows/docs-verify-build.yml

@@ -6,7 +6,7 @@ name: "Verify build (docs)"
 on:
     # Run on every push to a branch other than main that changes docs/
     push:
-        branches-ignore: [main]
+        branches-ignore: [main, "deploy/**"]
         paths:
             - "docs/**"
             - ".github/workflows/docs-verify-build.yml"

+ 2 - 2
.github/workflows/mobile-crowdin.yml

@@ -2,12 +2,12 @@ name: "Sync Crowdin translations (mobile)"
 
 on:
     push:
+        branches: [main]
         paths:
             # Run workflow when mobiles's intl_en.arb is changed
             - "mobile/lib/l10n/intl_en.arb"
             # Or the workflow itself is changed
             - ".github/workflows/mobile-crowdin.yml"
-        branches: [main]
     schedule:
         # See: [Note: Run workflow on specific days of the week]
         - cron: "40 1 * * 2,5"
@@ -28,7 +28,7 @@ jobs:
                   base_path: "mobile/"
                   config: "mobile/crowdin.yml"
                   upload_sources: true
-                  upload_translations: true
+                  upload_translations: false
                   download_translations: true
                   localization_branch_name: crowdin-translations-mobile
                   create_pull_request: true

+ 1 - 1
.github/workflows/mobile-lint.yml

@@ -3,7 +3,7 @@ name: "Lint (mobile)"
 on:
     # Run on every push to a branch other than main that changes mobile/
     push:
-        branches-ignore: [main, f-droid]
+        branches-ignore: [main, f-droid, "deploy/**"]
         paths:
             - "mobile/**"
             - ".github/workflows/mobile-lint.yml"

+ 1 - 1
.github/workflows/server-lint.yml

@@ -3,7 +3,7 @@ name: "Lint (server)"
 on:
     # Run on every push to a branch other than main that changes server/
     push:
-        branches-ignore: [main]
+        branches-ignore: [main, "deploy/**"]
         paths:
             - "server/**"
             - ".github/workflows/server-lint.yml"

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

@@ -2,12 +2,12 @@ name: "Sync Crowdin translations (web)"
 
 on:
     push:
+        branches: [main]
         paths:
             # Run workflow when web's en-US/translation.json is changed
             - "web/apps/photos/public/locales/en-US/translation.json"
             # Or the workflow itself is changed
             - ".github/workflows/web-crowdin.yml"
-        branches: [main]
     schedule:
         # [Note: Run workflow on specific days of the week]
         #
@@ -34,7 +34,7 @@ jobs:
                   base_path: "web/"
                   config: "web/crowdin.yml"
                   upload_sources: true
-                  upload_translations: true
+                  upload_translations: false
                   download_translations: true
                   localization_branch_name: crowdin-translations-web
                   create_pull_request: true

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

@@ -3,7 +3,7 @@ name: "Lint (web)"
 on:
     # Run on every push to a branch other than main that changes web/
     push:
-        branches-ignore: [main]
+        branches-ignore: [main, "deploy/**"]
         paths:
             - "web/**"
             - ".github/workflows/web-lint.yml"

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

@@ -145,6 +145,7 @@
   "lostDeviceTitle": "Gerät verloren?",
   "twoFactorAuthTitle": "Zwei-Faktor-Authentifizierung",
   "passkeyAuthTitle": "Passkey Authentifizierung",
+  "verifyPasskey": "Passkey verifizieren",
   "recoverAccount": "Konto wiederherstellen",
   "enterRecoveryKeyHint": "Geben Sie Ihren Wiederherstellungsschlüssel ein",
   "recover": "Wiederherstellen",
@@ -407,6 +408,7 @@
   "hearUsWhereTitle": "Wie hast du von Ente erfahren? (optional)",
   "hearUsExplanation": "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!",
   "waitingForBrowserRequest": "Warten auf Browseranfrage...",
+  "waitingForVerification": "Warte auf Bestätigung...",
   "passkey": "Passkey",
   "developerSettingsWarning": "Sind Sie sicher, dass Sie die Entwicklereinstellungen ändern möchten?",
   "developerSettings": "Entwicklereinstellungen",

+ 0 - 1
desktop/.eslintignore

@@ -1 +0,0 @@
-ui/*

+ 0 - 55
desktop/.eslintrc

@@ -1,55 +0,0 @@
-{
-    "root": true,
-    "env": {
-        "browser": true,
-        "es2021": true,
-        "node": true
-    },
-    "extends": [
-        "eslint:recommended",
-        "plugin:@typescript-eslint/eslint-recommended",
-        "google",
-        "prettier"
-    ],
-    "parser": "@typescript-eslint/parser",
-    "parserOptions": {
-        "ecmaFeatures": {
-            "jsx": true
-        },
-        "ecmaVersion": 12,
-        "sourceType": "module"
-    },
-    "plugins": ["@typescript-eslint"],
-    "rules": {
-        "indent": "off",
-        "class-methods-use-this": "off",
-        "react/prop-types": "off",
-        "react/display-name": "off",
-        "react/no-unescaped-entities": "off",
-        "no-unused-vars": "off",
-        "@typescript-eslint/no-unused-vars": ["error"],
-        "require-jsdoc": "off",
-        "valid-jsdoc": "off",
-        "max-len": "off",
-        "new-cap": "off",
-        "no-invalid-this": "off",
-        "eqeqeq": "error",
-        "object-curly-spacing": ["error", "always"],
-        "space-before-function-paren": "off",
-        "operator-linebreak": [
-            "error",
-            "after",
-            { "overrides": { "?": "before", ":": "before" } }
-        ]
-    },
-    "settings": {
-        "react": {
-            "version": "detect"
-        }
-    },
-    "globals": {
-        "JSX": "readonly",
-        "NodeJS": "readonly",
-        "ReadableStreamDefaultController": "readonly"
-    }
-}

+ 31 - 0
desktop/.eslintrc.js

@@ -0,0 +1,31 @@
+/* eslint-env node */
+module.exports = {
+    extends: [
+        "eslint:recommended",
+        "plugin:@typescript-eslint/eslint-recommended",
+        /* What we really want eventually */
+        // "plugin:@typescript-eslint/strict-type-checked",
+        // "plugin:@typescript-eslint/stylistic-type-checked",
+    ],
+    /* Temporarily disable some rules
+       Enhancement: Remove me */
+    rules: {
+        "no-unused-vars": "off",
+    },
+    /* Temporarily add a global
+       Enhancement: Remove me */
+    globals: {
+        NodeJS: "readonly",
+    },
+    plugins: ["@typescript-eslint"],
+    parser: "@typescript-eslint/parser",
+    parserOptions: {
+        project: true,
+    },
+    root: true,
+    ignorePatterns: [".eslintrc.js", "app", "out", "dist"],
+    env: {
+        es2022: true,
+        node: true,
+    },
+};

+ 2 - 1
desktop/.gitignore

@@ -14,7 +14,8 @@ node_modules/
 # tsc transpiles src/**/*.ts and emits the generated JS into app
 app/
 
-# out is a symlink to the photos web app's dir
+# out is a symlink to the photos web app's out dir, which contains the built up
+# photos app.
 out
 
 # electron-builder

+ 1 - 0
desktop/.prettierrc.json

@@ -1,5 +1,6 @@
 {
     "tabWidth": 4,
+    "proseWrap": "always",
     "plugins": [
         "prettier-plugin-organize-imports",
         "prettier-plugin-packagejson"

+ 26 - 13
desktop/CHANGELOG.md

@@ -131,7 +131,8 @@
 
 ### Photo Editor
 
-Check out our [blog](https://ente.io/blog/introducing-web-desktop-photo-editor/) to know about feature and functionalities.
+Check out our [blog](https://ente.io/blog/introducing-web-desktop-photo-editor/)
+to know about feature and functionalities.
 
 ## v1.6.47
 
@@ -146,15 +147,19 @@ Check out our [blog](https://ente.io/blog/introducing-web-desktop-photo-editor/)
 
 ### Bug Fixes
 
--   Fixes OOM crashes during file upload [#1379](https://github.com/ente-io/photos-web/pull/1379)
+-   Fixes OOM crashes during file upload
+    [#1379](https://github.com/ente-io/photos-web/pull/1379)
 
 ## v1.6.45
 
 ### Bug Fixes
 
--   Fixed app keeps reloading issue [#235](https://github.com/ente-io/photos-desktop/pull/235)
--   Fixed dng and arw preview issue [#1378](https://github.com/ente-io/photos-web/pull/1378)
--   Added view crash report option (help menu) for user to share electron crash report locally
+-   Fixed app keeps reloading issue
+    [#235](https://github.com/ente-io/photos-desktop/pull/235)
+-   Fixed dng and arw preview issue
+    [#1378](https://github.com/ente-io/photos-web/pull/1378)
+-   Added view crash report option (help menu) for user to share electron crash
+    report locally
 
 ## v1.6.44
 
@@ -166,23 +171,28 @@ Check out our [blog](https://ente.io/blog/introducing-web-desktop-photo-editor/)
 
 -   #### Check for update and changelog option
 
-    Added options to check for update manually and a view changelog via the app menubar
+    Added options to check for update manually and a view changelog via the app
+    menubar
 
 -   #### Opt out of crash reporting
 
-    Added option to out of a crash reporting, it can accessed from the settings -> preferences -> disable crash reporting
+    Added option to out of a crash reporting, it can accessed from the settings
+    -> preferences -> disable crash reporting
 
 -   #### Type search
 
-    Added new search option to search files based on file type i.e, image, video, live-photo.
+    Added new search option to search files based on file type i.e, image,
+    video, live-photo.
 
 -   #### Manual Convert Button
 
-    In case the video is not playable, Now there is a convert button which can be used to trigger conversion of the video to supported format.
+    In case the video is not playable, Now there is a convert button which can
+    be used to trigger conversion of the video to supported format.
 
 -   #### File Download Progress
 
-    The file loader now also shows the exact percentage download progress, instead of just a simple loader.
+    The file loader now also shows the exact percentage download progress,
+    instead of just a simple loader.
 
 -   #### Bug fixes & other enhancements
 
@@ -198,16 +208,19 @@ Check out our [blog](https://ente.io/blog/introducing-web-desktop-photo-editor/)
 
 -   #### Email verification
 
-    We have now made email verification optional, so you can sign in with just your email address and password, without waiting for a verification code.
+    We have now made email verification optional, so you can sign in with just
+    your email address and password, without waiting for a verification code.
 
     You can opt in / out of email verification from Settings > Security.
 
 -   #### Download Album
 
-    You can now chose the download location for downloading albums. Along with that we have also added progress bar for album download.
+    You can now chose the download location for downloading albums. Along with
+    that we have also added progress bar for album download.
 
 -   #### Bug fixes & other enhancements
 
     We have squashed a few pesky bugs that were reported by our community
 
-    If you would like to help us improve ente, come join the party @ ente.io/community!
+    If you would like to help us improve ente, come join the party @
+    ente.io/community!

+ 15 - 8
desktop/README.md

@@ -2,31 +2,38 @@
 
 The sweetness of Ente Photos, right on your computer. Linux, Windows and macOS.
 
-You can [**download** a pre-built binary from
-releases](https://github.com/ente-io/photos-desktop/releases/latest).
+You can
+[**download** a pre-built binary from releases](https://github.com/ente-io/photos-desktop/releases/latest).
 
 To know more about Ente, see [our main README](../README.md) or visit
 [ente.io](https://ente.io).
 
 ## 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
+
+```sh
+git submodule update --init --recursive
+```
+
 Install dependencies
 
 ```sh
 yarn install
 ```
 
-Run in development mode (with hot reload)
+Run in development mode (supports hot reload for the renderer process)
 
 ```sh
 yarn dev
 ```
 
-> [!CAUTION]
->
-> `yarn dev` is currently not working (we'll fix soon). If you just want to
-> build from source and use the generated binary, use `yarn build`.
-
 Or create a binary for your platform
 
 ```sh

+ 0 - 30
desktop/build/error.html

@@ -1,30 +0,0 @@
-<!doctype html>
-<html lang="en">
-    <head>
-        <meta charset="UTF-8" />
-        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
-        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-        <title>ente Photos</title>
-    </head>
-
-    <body style="background-color: black">
-        <div
-            style="
-                height: 95vh;
-                width: 96vw;
-                display: grid;
-                place-items: center;
-                color: white;
-            "
-        >
-            <div>
-                <div style="margin-bottom: 10px">
-                    Site unreachable, please try again later
-                </div>
-                <button onClick="window[`ElectronAPIs`].reloadWindow()">
-                    Reload
-                </button>
-            </div>
-        </div>
-    </body>
-</html>

+ 0 - 24
desktop/build/version.html

@@ -1,24 +0,0 @@
-<!doctype html>
-<html>
-    <head>
-        <title>Electron Updater Example</title>
-    </head>
-    <body>
-        Current version: <span id="version">vX.Y.Z</span>
-        <div id="messages"></div>
-        <script>
-            // Display the current version
-            let version = window.location.hash.substring(1);
-            document.getElementById("version").innerText = version;
-
-            // Listen for messages
-            const { ipcRenderer } = require("electron");
-            ipcRenderer.on("message", function (event, text) {
-                var container = document.getElementById("messages");
-                var message = document.createElement("div");
-                message.innerHTML = text;
-                container.appendChild(message);
-            });
-        </script>
-    </body>
-</html>

+ 47 - 7
desktop/docs/dependencies.md

@@ -1,14 +1,54 @@
 # Dependencies
 
-See [web/docs/dependencies.md](../../web/docs/dependencies.md) for general web
-specific dependencies. See [electron.md](electron.md) for our main dependency,
-Electron. The rest of this document describes the remaining, desktop specific
-dependencies that are used by the Photos desktop app.
+## Electron
 
-## Electron related
+[Electron](https://www.electronjs.org) is a cross-platform (Linux, Windows,
+macOS) way for creating desktop apps using TypeScript.
+
+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
+a _renderer_ process.
+
+-   The _main_ process is runs the embedded node. This process can deal with the
+    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
+    `tsc` into JavaScript in the `build/app/` directory, which gets bundled in
+    the generated app's binary and is loaded by the node (main) process when the
+    app starts.
+
+-   The _renderer_ process is a regular web app that gets loaded into the
+    embedded Chromium. When the main process starts, it creates a new "window"
+    that shows this embedded Chromium. In our case, we build and bundle a static
+    export of the [Photos web app](../web/README.md) in the generated app. This
+    gets loaded by the embedded Chromium at runtime, acting as the app's UI.
+
+There is also a third environment that gets temporarily created:
+
+-   The [preload script](../src/preload.ts) acts as a gateway between the _main_
+    and the _renderer_ process. It runs in its own isolated environment.
+
+### electron-builder
+
+[Electron Builder](https://www.electron.build) is used for packaging the app for
+distribution.
+
+During the build it uses
+[electron-builder-notarize](https://github.com/karaggeorge/electron-builder-notarize)
+to notarize the macOS binary.
 
 ### next-electron-server
 
 This spins up a server for serving files using a protocol handler inside our
-Electron process. This allows us to directly use the output produced by `next
-build` for loading into our renderer process.
+Electron process. This allows us to directly use the output produced by
+`next build` for loading into our renderer process.
+
+## DX
+
+See [web/docs/dependencies#DX](../../web/docs/dependencies.md#dx) for the
+general development experience related dependencies like TypeScript etc, which
+are similar to that in the web code.
+
+Some extra ones specific to the code here are:
+
+* [concurrently](https://github.com/open-cli-tools/concurrently) for spawning
+  parallel tasks when we do `yarn dev`.

+ 42 - 3
desktop/docs/dev.md

@@ -1,4 +1,43 @@
-# Development tips
+# Development
 
--   `yarn build:quick` is a variant of `yarn build` that uses the
-    `--config.compression=store` flag to (slightly) speed up electron-builder.
+## Yarn commands
+
+### yarn dev
+
+Launch the app in development mode:
+
+-   Transpiles the files in `src/` and starts the main process.
+
+-   Runs a development server for the renderer (with hot module reload).
+
+-   Starts tsc in watch mode to recompile the JS files used by the main process.
+    Note that the main process is not restarted on changes automatically, you'll
+    still need to restart the app manually – running tsc in watch mode is still
+    useful to notice any errors.
+
+### yarn build
+
+Build a binary for your current platform.
+
+Note that our actual releases use a
+[GitHub workflow](../.github/workflows/desktop-release.yml) that is similar to
+this, except it builds binaries for all the supported OSes and uses production
+signing credentials.
+
+During development, you might find `yarn build:quick` helpful. It is a variant
+of `yarn build` that omits some steps to build a binary quicker, something that
+can be useful during development.
+
+### postinstall
+
+When using native node modules (those written in C/C++), we need to ensure they
+are built against `electron`'s packaged `node` version. We use
+[electron-builder](https://www.electron.build/cli)'s `install-app-deps` command
+to rebuild those modules automatically after each `yarn install` by invoking it
+in as the `postinstall` step in our package.json.
+
+### lint and lint-fix
+
+Use `yarn lint` to check that your code formatting is as expected, and that
+there are no linter errors. Use `yarn lint-fix` to try and automatically fix the
+issues.

+ 0 - 21
desktop/docs/electron.md

@@ -1,21 +0,0 @@
-# Electron
-
-[Electron](https://www.electronjs.org) is a cross-platform (Linux, Windows,
-macOS) way for creating desktop apps using TypeScript.
-
-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
-a _renderer_ process.
-
--   The _main_ process is runs the embedded node. This process can deal with the
-    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 `tsc`
-    into JavaScript in the `build/app/` directory, which gets bundled in the
-    generated app's binary and is loaded by the node (main) process when the app
-    starts.
-
--   The _renderer_ process is a regular web app that gets loaded into the embedded
-    Chromium. When the main process starts, it creates a new "window" that shows
-    this embedded Chromium. In our case, we build and bundle a static export of
-    the [Photos web app](../web/README.md) in the generated app. This gets loaded
-    by the embedded Chromium at runtime, acting as the app's UI.

+ 19 - 19
desktop/docs/release.md

@@ -20,11 +20,11 @@ So the process for doing a release would be.
 
 4. Commit and push to remote
 
-   ```sh
-   git add package.json && git commit -m 'Release v1.x.x'
-   git tag v1.x.x
-   git push && git push --tags
-   ```
+    ```sh
+    git add package.json && git commit -m 'Release v1.x.x'
+    git tag v1.x.x
+    git push && git push --tags
+    ```
 
 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.
@@ -42,9 +42,9 @@ To wrap up, we also need to merge back these changes into main. So for that,
 The GitHub Action runs on Windows, Linux and macOS. It produces the artifacts
 defined in the `build` value in `package.json`.
 
-* Windows - An NSIS installer.
-* Linux - An AppImage, and 3 other packages (`.rpm`, `.deb`, `.pacman`)
-* macOS - A universal DMG
+-   Windows - An NSIS installer.
+-   Linux - An AppImage, and 3 other packages (`.rpm`, `.deb`, `.pacman`)
+-   macOS - A universal DMG
 
 Additionally, the GitHub action notarizes the macOS DMG. For this it needs
 credentials provided via GitHub secrets.
@@ -70,19 +70,19 @@ 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:
 
-* The website automatically redirects to the latest release on GitHub when
-  people try to download.
+-   The website automatically redirects to the latest release on GitHub when
+    people try to download.
 
-* The file formats with support auto update (Windows `exe`, the Linux AppImage
-  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
-  updates).
+-   The file formats with support auto update (Windows `exe`, the Linux AppImage
+    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
+    updates).
 
-* 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, now
-  their maintainers automatically bump the SHA, version number and the (derived
-  from the version) URL in the formula when their tools notice a new release on
-  our GitHub.
+-   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,
+    now their maintainers automatically bump the SHA, version number and the
+    (derived from the version) URL in the formula when their tools notice a new
+    release on our GitHub.
 
 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

+ 30 - 0
desktop/electron-builder.yml

@@ -0,0 +1,30 @@
+appId: io.ente.bhari-frame
+artifactName: ${productName}-${version}-${arch}.${ext}
+nsis:
+    deleteAppDataOnUninstall: true
+linux:
+    target:
+        - target: AppImage
+          arch: [x64, arm64]
+        - target: deb
+          arch: [x64, arm64]
+        - target: rpm
+          arch: [x64, arm64]
+        - target: pacman
+          arch: [x64, arm64]
+    icon: ./resources/icon.icns
+    category: Photography
+mac:
+    target:
+        target: default
+        arch: [universal]
+    category: public.app-category.photography
+    hardenedRuntime: true
+    x64ArchFiles: Contents/Resources/ggmlclip-mac
+afterSign: electron-builder-notarize
+extraFiles:
+    - from: build
+      to: resources
+files:
+    - app/**/*
+    - out

+ 10 - 92
desktop/package.json

@@ -8,7 +8,7 @@
     "scripts": {
         "build": "yarn build-renderer && yarn build-main",
         "build-main": "tsc && electron-builder",
-        "build-main:quick": "tsc && electron-builder --config.compression=store",
+        "build-main:quick": "tsc && electron-builder --dir --config.compression=store --config.mac.identity=null",
         "build-renderer": "cd ../web && yarn install && yarn build:photos && cd ../desktop && rm -f out && ln -sf ../web/apps/photos/out",
         "build:quick": "yarn build-renderer && yarn build-main:quick",
         "dev": "concurrently --names 'main,rndr,tscw' \"yarn dev-main\" \"yarn dev-renderer\" \"yarn dev-main-watch\"",
@@ -16,8 +16,8 @@
         "dev-main-watch": "tsc --watch --preserveWatchOutput",
         "dev-renderer": "cd ../web && yarn install && yarn dev:photos",
         "postinstall": "electron-builder install-app-deps",
-        "lint": "yarn prettier --check . && eslint \"src/**/*.ts\"",
-        "lint-fix": "yarn prettier --write . && eslint --fix src"
+        "lint": "yarn prettier --check . && eslint --ext .ts src",
+        "lint-fix": "yarn prettier --write . && eslint --fix --ext .ts src"
     },
     "dependencies": {
         "any-shell-escape": "^0.1.1",
@@ -25,111 +25,29 @@
         "chokidar": "^3.5.3",
         "compare-versions": "^6.1.0",
         "electron-log": "^4.3.5",
-        "electron-reload": "^2.0.0-alpha.1",
         "electron-store": "^8.0.1",
         "electron-updater": "^4.3.8",
         "ffmpeg-static": "^5.1.0",
-        "get-folder-size": "^2.0.1",
         "html-entities": "^2.4.0",
         "jpeg-js": "^0.4.4",
         "next-electron-server": "^1",
-        "node-fetch": "^2.6.7",
         "node-stream-zip": "^1.15.0",
-        "onnxruntime-node": "^1.16.3",
-        "promise-fs": "^2.1.1"
+        "onnxruntime-node": "^1.16.3"
     },
     "devDependencies": {
         "@types/auto-launch": "^5.0.2",
         "@types/ffmpeg-static": "^3.0.1",
-        "@types/get-folder-size": "^2.0.0",
-        "@types/node": "18.15.0",
-        "@types/node-fetch": "^2.6.2",
-        "@types/promise-fs": "^2.1.1",
-        "@typescript-eslint/eslint-plugin": "^5.28.0",
-        "@typescript-eslint/parser": "^5.28.0",
-        "concurrently": "^7.0.0",
+        "@typescript-eslint/eslint-plugin": "^7",
+        "@typescript-eslint/parser": "^7",
+        "concurrently": "^8",
         "electron": "^25.8.4",
         "electron-builder": "^24.6.4",
         "electron-builder-notarize": "^1.2.0",
-        "electron-download": "^4.1.1",
-        "eslint": "^7.23.0",
-        "eslint-config-google": "^0.14.0",
-        "eslint-config-prettier": "^8.5.0",
+        "eslint": "^8",
         "prettier": "^3",
         "prettier-plugin-organize-imports": "^3.2",
         "prettier-plugin-packagejson": "^2.4",
-        "typescript": "^4.2.3"
+        "typescript": "^5"
     },
-    "build": {
-        "appId": "io.ente.bhari-frame",
-        "artifactName": "${productName}-${version}-${arch}.${ext}",
-        "nsis": {
-            "deleteAppDataOnUninstall": true
-        },
-        "linux": {
-            "target": [
-                {
-                    "target": "AppImage",
-                    "arch": [
-                        "x64",
-                        "arm64"
-                    ]
-                },
-                {
-                    "target": "deb",
-                    "arch": [
-                        "x64",
-                        "arm64"
-                    ]
-                },
-                {
-                    "target": "rpm",
-                    "arch": [
-                        "x64",
-                        "arm64"
-                    ]
-                },
-                {
-                    "target": "pacman",
-                    "arch": [
-                        "x64",
-                        "arm64"
-                    ]
-                }
-            ],
-            "icon": "./resources/icon.icns",
-            "category": "Photography"
-        },
-        "mac": {
-            "target": {
-                "target": "default",
-                "arch": [
-                    "universal"
-                ]
-            },
-            "category": "public.app-category.photography",
-            "hardenedRuntime": true,
-            "x64ArchFiles": "Contents/Resources/ggmlclip-mac"
-        },
-        "afterSign": "electron-builder-notarize",
-        "asarUnpack": [
-            "node_modules/ffmpeg-static/bin/${os}/${arch}/ffmpeg",
-            "node_modules/ffmpeg-static/index.js",
-            "node_modules/ffmpeg-static/package.json"
-        ],
-        "extraFiles": [
-            {
-                "from": "build",
-                "to": "resources"
-            }
-        ],
-        "files": [
-            "app/**/*",
-            "out"
-        ]
-    },
-    "productName": "ente",
-    "standard": {
-        "parser": "babel-eslint"
-    }
+    "productName": "ente"
 }

+ 0 - 52
desktop/src/api/cache.ts

@@ -1,52 +0,0 @@
-import { ipcRenderer } from "electron/renderer";
-import path from "path";
-import { existsSync, mkdir, rmSync } from "promise-fs";
-import { DiskCache } from "../services/diskCache";
-
-const ENTE_CACHE_DIR_NAME = "ente";
-
-export const getCacheDirectory = async () => {
-    const customCacheDir = await getCustomCacheDirectory();
-    if (customCacheDir && existsSync(customCacheDir)) {
-        return customCacheDir;
-    }
-    const defaultSystemCacheDir = await ipcRenderer.invoke("get-path", "cache");
-    return path.join(defaultSystemCacheDir, ENTE_CACHE_DIR_NAME);
-};
-
-const getCacheBucketDir = async (cacheName: string) => {
-    const cacheDir = await getCacheDirectory();
-    const cacheBucketDir = path.join(cacheDir, cacheName);
-    return cacheBucketDir;
-};
-
-export async function openDiskCache(
-    cacheName: string,
-    cacheLimitInBytes?: number,
-) {
-    const cacheBucketDir = await getCacheBucketDir(cacheName);
-    if (!existsSync(cacheBucketDir)) {
-        await mkdir(cacheBucketDir, { recursive: true });
-    }
-    return new DiskCache(cacheBucketDir, cacheLimitInBytes);
-}
-
-export async function deleteDiskCache(cacheName: string) {
-    const cacheBucketDir = await getCacheBucketDir(cacheName);
-    if (existsSync(cacheBucketDir)) {
-        rmSync(cacheBucketDir, { recursive: true, force: true });
-        return true;
-    } else {
-        return false;
-    }
-}
-
-export async function setCustomCacheDirectory(
-    directory: string,
-): Promise<void> {
-    await ipcRenderer.invoke("set-custom-cache-directory", directory);
-}
-
-async function getCustomCacheDirectory(): Promise<string> {
-    return await ipcRenderer.invoke("get-custom-cache-directory");
-}

+ 0 - 54
desktop/src/api/clip.ts

@@ -1,54 +0,0 @@
-import { ipcRenderer } from "electron";
-import { writeStream } from "../services/fs";
-import { Model } from "../types";
-import { isExecError, parseExecError } from "../utils/error";
-
-export async function computeImageEmbedding(
-    model: Model,
-    imageData: Uint8Array,
-): Promise<Float32Array> {
-    let tempInputFilePath = null;
-    try {
-        tempInputFilePath = await ipcRenderer.invoke("get-temp-file-path", "");
-        const imageStream = new Response(imageData.buffer).body;
-        await writeStream(tempInputFilePath, imageStream);
-        const embedding = await ipcRenderer.invoke(
-            "compute-image-embedding",
-            model,
-            tempInputFilePath,
-        );
-        return embedding;
-    } catch (err) {
-        if (isExecError(err)) {
-            const parsedExecError = parseExecError(err);
-            throw Error(parsedExecError);
-        } else {
-            throw err;
-        }
-    } finally {
-        if (tempInputFilePath) {
-            await ipcRenderer.invoke("remove-temp-file", tempInputFilePath);
-        }
-    }
-}
-
-export async function computeTextEmbedding(
-    model: Model,
-    text: string,
-): Promise<Float32Array> {
-    try {
-        const embedding = await ipcRenderer.invoke(
-            "compute-text-embedding",
-            model,
-            text,
-        );
-        return embedding;
-    } catch (err) {
-        if (isExecError(err)) {
-            const parsedExecError = parseExecError(err);
-            throw Error(parsedExecError);
-        } else {
-            throw err;
-        }
-    }
-}

+ 0 - 39
desktop/src/api/common.ts

@@ -1,39 +0,0 @@
-import { ipcRenderer } from "electron/renderer";
-import { logError } from "../services/logging";
-
-export const selectDirectory = async (): Promise<string> => {
-    try {
-        return await ipcRenderer.invoke("select-dir");
-    } catch (e) {
-        logError(e, "error while selecting root directory");
-    }
-};
-
-export const getAppVersion = async (): Promise<string> => {
-    try {
-        return await ipcRenderer.invoke("get-app-version");
-    } catch (e) {
-        logError(e, "failed to get release version");
-        throw e;
-    }
-};
-
-export const openDirectory = async (dirPath: string): Promise<void> => {
-    try {
-        await ipcRenderer.invoke("open-dir", dirPath);
-    } catch (e) {
-        logError(e, "error while opening directory");
-        throw e;
-    }
-};
-
-export const getPlatform = async (): Promise<"mac" | "windows" | "linux"> => {
-    try {
-        return await ipcRenderer.invoke("get-platform");
-    } catch (e) {
-        logError(e, "failed to get platform");
-        throw e;
-    }
-};
-
-export { logToDisk, openLogDirectory } from "../services/logging";

+ 1 - 1
desktop/src/api/electronStore.ts

@@ -1,4 +1,4 @@
-import { logError } from "../services/logging";
+import { logError } from "../main/log";
 import { keysStore } from "../stores/keys.store";
 import { safeStorageStore } from "../stores/safeStorage.store";
 import { uploadStatusStore } from "../stores/upload.store";

+ 0 - 23
desktop/src/api/export.ts

@@ -1,23 +0,0 @@
-import * as fs from "promise-fs";
-import { writeStream } from "./../services/fs";
-
-export const exists = (path: string) => {
-    return fs.existsSync(path);
-};
-
-export const checkExistsAndCreateDir = async (dirPath: string) => {
-    if (!fs.existsSync(dirPath)) {
-        await fs.mkdir(dirPath);
-    }
-};
-
-export const saveStreamToDisk = async (
-    filePath: string,
-    fileStream: ReadableStream<Uint8Array>,
-) => {
-    await writeStream(filePath, fileStream);
-};
-
-export const saveFileToDisk = async (path: string, fileData: string) => {
-    await fs.writeFile(path, fileData);
-};

+ 1 - 1
desktop/src/api/ffmpeg.ts

@@ -1,7 +1,7 @@
 import { ipcRenderer } from "electron";
 import { existsSync } from "fs";
 import { writeStream } from "../services/fs";
-import { logError } from "../services/logging";
+import { logError } from "../main/log";
 import { ElectronFile } from "../types";
 
 export async function runFFmpegCmd(

+ 0 - 8
desktop/src/api/fs.ts

@@ -5,11 +5,3 @@ export async function getDirFiles(dirPath: string) {
     const electronFiles = await Promise.all(files.map(getElectronFile));
     return electronFiles;
 }
-export {
-    deleteFile,
-    deleteFolder,
-    isFolder,
-    moveFile,
-    readTextFile,
-    rename,
-} from "../services/fs";

+ 1 - 1
desktop/src/api/imageProcessor.ts

@@ -2,7 +2,7 @@ import { ipcRenderer } from "electron/renderer";
 import { existsSync } from "fs";
 import { CustomErrors } from "../constants/errors";
 import { writeStream } from "../services/fs";
-import { logError } from "../services/logging";
+import { logError } from "../main/log";
 import { ElectronFile } from "../types";
 import { isPlatform } from "../utils/common/platform";
 

+ 1 - 1
desktop/src/api/safeStorage.ts

@@ -1,5 +1,5 @@
 import { ipcRenderer } from "electron";
-import { logError } from "../services/logging";
+import { logError } from "../main/log";
 import { safeStorageStore } from "../stores/safeStorage.store";
 
 export async function setEncryptionKey(encryptionKey: string) {

+ 0 - 19
desktop/src/api/system.ts

@@ -1,13 +1,6 @@
 import { ipcRenderer } from "electron";
 import { AppUpdateInfo } from "../types";
 
-export const sendNotification = (content: string) => {
-    ipcRenderer.send("send-notification", content);
-};
-export const reloadWindow = () => {
-    ipcRenderer.send("reload-window");
-};
-
 export const registerUpdateEventListener = (
     showUpdateDialog: (updateInfo: AppUpdateInfo) => void,
 ) => {
@@ -23,15 +16,3 @@ export const registerForegroundEventListener = (onForeground: () => void) => {
         onForeground();
     });
 };
-
-export const updateAndRestart = () => {
-    ipcRenderer.send("update-and-restart");
-};
-
-export const skipAppUpdate = (version: string) => {
-    ipcRenderer.send("skip-app-update", version);
-};
-
-export const muteUpdateNotification = (version: string) => {
-    ipcRenderer.send("mute-update-notification", version);
-};

+ 1 - 1
desktop/src/api/upload.ts

@@ -1,5 +1,5 @@
 import { ipcRenderer } from "electron";
-import { logError } from "../services/logging";
+import { logError } from "../main/log";
 import {
     getElectronFilesFromGoogleZip,
     getSavedFilePaths,

+ 94 - 30
desktop/src/main.ts

@@ -1,15 +1,27 @@
-import { app, BrowserWindow } from "electron";
-import electronReload from "electron-reload";
+/**
+ * @file Entry point for the main (Node.js) process of our Electron app.
+ *
+ * The code in this file is invoked by Electron when our app starts -
+ * Conceptually (after all the transpilation etc has happened) this can be
+ * thought of `electron main.ts`. We're running in the context of the so called
+ * "main" process which runs in a Node.js environment.
+ *
+ * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
+ */
+import * as log from "electron-log";
+import { app, BrowserWindow } from "electron/main";
 import serveNextAt from "next-electron-server";
+import { existsSync } from "node:fs";
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+import { isDev } from "./main/general";
+import { logErrorSentry, setupLogging } from "./main/log";
 import { initWatcher } from "./services/chokidar";
-import { isDev } from "./utils/common";
 import { addAllowOriginHeader } from "./utils/cors";
 import { createWindow } from "./utils/createWindow";
 import { setupAppEventEmitter } from "./utils/events";
 import setupIpcComs from "./utils/ipcComms";
-import { setupLogging } from "./utils/logging";
 import {
-    enableSharedArrayBufferSupport,
     handleDockIconHideOnAutoLaunch,
     handleDownloads,
     handleExternalLinks,
@@ -19,9 +31,6 @@ import {
     setupMainMenu,
     setupTrayItem,
 } from "./utils/main";
-import { setupMainProcessStatsLogger } from "./utils/processStats";
-
-let mainWindow: BrowserWindow;
 
 let appIsQuitting = false;
 
@@ -43,19 +52,6 @@ export const setIsUpdateAvailable = (value: boolean): void => {
     updateIsAvailable = value;
 };
 
-/**
- * Hot reload the main process if anything changes in the source directory that
- * we're running from.
- *
- * In particular, this gets triggered when the `tsc -w` rebuilds JS files in the
- * `app/` directory when we change the TS files in the `src/` directory.
- */
-const setupMainHotReload = () => {
-    if (isDev) {
-        electronReload(__dirname, {});
-    }
-};
-
 /**
  * The URL where the renderer HTML is being served from.
  */
@@ -78,16 +74,75 @@ const setupRendererServer = () => {
     serveNextAt(rendererURL);
 };
 
-setupMainHotReload();
-setupRendererServer();
-setupLogging(isDev);
+function enableSharedArrayBufferSupport() {
+    app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer");
+}
 
-const gotTheLock = app.requestSingleInstanceLock();
-if (!gotTheLock) {
-    app.quit();
-} else {
+/**
+ * [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 same web-native caching mechanism 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. We set it to a large value, 5 GB (5 * 1024 *
+ * 1024 * 1024 = 5368709120)
+ * 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
+ */
+const increaseDiskCache = () => {
+    app.commandLine.appendSwitch("disk-cache-size", "5368709120");
+};
+
+/**
+ * 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 (See:
+ * [Note: Increased disk cache for the desktop app]).
+ *
+ * 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.
+ */
+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`.
+    //
+    // It might be an issue in the types, since at runtime it seems to work. For
+    // example, on macOS I get `~/Library/Caches`.
+    //
+    // Irrespective, we replicate the original behaviour so that we get back the
+    // same path that the old got was getting.
+    //
+    // @ts-expect-error
+    const cacheDir = path.join(app.getPath("cache"), "ente");
+    if (existsSync(cacheDir)) {
+        log.info(`Removing legacy disk cache from ${cacheDir}`);
+        await fs.rm(cacheDir, { recursive: true });
+    }
+};
+
+const main = () => {
+    setupLogging(isDev);
+
+    const gotTheLock = app.requestSingleInstanceLock();
+    if (!gotTheLock) {
+        app.quit();
+        return;
+    }
+
+    let mainWindow: BrowserWindow;
+
+    setupRendererServer();
     handleDockIconHideOnAutoLaunch();
+    increaseDiskCache();
     enableSharedArrayBufferSupport();
+
     app.on("second-instance", () => {
         // Someone tried to run a second instance, we should focus our window.
         if (mainWindow) {
@@ -104,7 +159,6 @@ if (!gotTheLock) {
     // Some APIs can only be used after this event occurs.
     app.on("ready", async () => {
         logSystemInfo();
-        setupMainProcessStatsLogger();
         mainWindow = await createWindow();
         const tray = setupTrayItem(mainWindow);
         const watcher = initWatcher(mainWindow);
@@ -116,7 +170,17 @@ if (!gotTheLock) {
         handleExternalLinks(mainWindow);
         addAllowOriginHeader(mainWindow);
         setupAppEventEmitter(mainWindow);
+
+        try {
+            deleteLegacyDiskCacheDirIfExists();
+        } catch (e) {
+            // Log but otherwise ignore errors during non-critical startup
+            // actions
+            logErrorSentry(e, "Ignoring startup error");
+        }
     });
 
     app.on("before-quit", () => setIsAppQuitting(true));
-}
+};
+
+main();

+ 42 - 0
desktop/src/main/general.ts

@@ -0,0 +1,42 @@
+import { shell } from "electron"; /* TODO(MR): Why is this not in /main? */
+import { app } from "electron/main";
+import * as path from "node:path";
+
+/** `true` if the app is running in development mode. */
+export const isDev = !app.isPackaged;
+
+/**
+ * Open the given {@link dirPath} in the system's folder viewer.
+ *
+ * For example, on macOS this'll open {@link dirPath} in Finder.
+ */
+export const openDirectory = async (dirPath: string) => {
+    const res = await shell.openPath(path.normalize(dirPath));
+    // shell.openPath resolves with a string containing the error message
+    // corresponding to the failure if a failure occurred, otherwise "".
+    if (res) throw new Error(`Failed to open directory ${dirPath}: res`);
+};
+
+/**
+ * Return the path where the logs for the app are saved.
+ *
+ * [Note: Electron app paths]
+ *
+ * By default, these paths are at the following locations:
+ *
+ * - macOS: `~/Library/Application Support/ente`
+ * - Linux: `~/.config/ente`
+ * - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local\ente`
+ * - Windows: C:\Users\<you>\AppData\Local\<Your App Name>
+ *
+ * https://www.electronjs.org/docs/latest/api/app
+ *
+ */
+const logDirectoryPath = () => app.getPath("logs");
+
+/**
+ * Open the app's log directory in the system's folder viewer.
+ *
+ * @see {@link openDirectory}
+ */
+export const openLogDirectory = () => openDirectory(logDirectoryPath());

+ 43 - 0
desktop/src/main/ipc.ts

@@ -0,0 +1,43 @@
+/**
+ * @file Listen for IPC events sent/invoked by the renderer process, and route
+ * them to their correct handlers.
+ *
+ * This file is meant as a sibling to `preload.ts`, but this one runs in the
+ * context of the main process, and can import other files from `src/`.
+ */
+
+import { ipcMain } from "electron/main";
+import { appVersion } from "../services/appUpdater";
+import { openDirectory, openLogDirectory } from "./general";
+import { logToDisk } from "./log";
+
+// - General
+
+export const attachIPCHandlers = () => {
+    // Notes:
+    //
+    // The first parameter of the handler passed to `ipcMain.handle` is the
+    // `event`, and is usually ignored. The rest of the parameters are the
+    // arguments passed to `ipcRenderer.invoke`.
+    //
+    // [Note: Catching exception during .send/.on]
+    //
+    // While we can use ipcRenderer.send/ipcMain.on for one-way communication,
+    // that has the disadvantage that any exceptions thrown in the processing of
+    // the handler are not sent back to the renderer. So we use the
+    // ipcRenderer.invoke/ipcMain.handle 2-way pattern even for things that are
+    // conceptually one way. An exception (pun intended) to this is logToDisk,
+    // which is a primitive, frequently used, operation and shouldn't throw, so
+    // having its signature by synchronous is a bit convenient.
+
+    // - General
+
+    ipcMain.handle("appVersion", (_) => appVersion());
+
+    ipcMain.handle("openDirectory", (_, dirPath) => openDirectory(dirPath));
+
+    ipcMain.handle("openLogDirectory", (_) => openLogDirectory());
+
+    // See: [Note: Catching exception during .send/.on]
+    ipcMain.on("logToDisk", (_, msg) => logToDisk(msg));
+};

+ 34 - 0
desktop/src/main/log.ts

@@ -0,0 +1,34 @@
+import log from "electron-log";
+import { isDev } from "./general";
+
+export function setupLogging(isDev?: boolean) {
+    log.transports.file.fileName = "ente.log";
+    log.transports.file.maxSize = 50 * 1024 * 1024; // 50MB;
+    if (!isDev) {
+        log.transports.console.level = false;
+    }
+    log.transports.file.format =
+        "[{y}-{m}-{d}T{h}:{i}:{s}{z}] [{level}]{scope} {text}";
+}
+
+export const logToDisk = (message: string) => {
+    log.info(message);
+};
+
+export const logError = logErrorSentry;
+
+/** Deprecated, but no alternative yet */
+export function logErrorSentry(
+    error: any,
+    msg: string,
+    info?: Record<string, unknown>,
+) {
+    logToDisk(
+        `error: ${error?.name} ${error?.message} ${
+            error?.stack
+        } msg: ${msg} info: ${JSON.stringify(info)}`,
+    );
+    if (isDev) {
+        console.log(error, { msg, info });
+    }
+}

+ 409 - 60
desktop/src/preload.ts

@@ -1,45 +1,43 @@
-import {
-    deleteDiskCache,
-    getCacheDirectory,
-    openDiskCache,
-    setCustomCacheDirectory,
-} from "./api/cache";
-import { computeImageEmbedding, computeTextEmbedding } from "./api/clip";
-import {
-    getAppVersion,
-    getPlatform,
-    logToDisk,
-    openDirectory,
-    openLogDirectory,
-    selectDirectory,
-} from "./api/common";
-import { clearElectronStore } from "./api/electronStore";
-import {
-    checkExistsAndCreateDir,
-    exists,
-    saveFileToDisk,
-    saveStreamToDisk,
-} from "./api/export";
+/**
+ * @file The preload script
+ *
+ * The preload script runs in a renderer process before its web contents begin
+ * loading. During their execution they have access to a subset of Node.js APIs
+ * and imports. Its purpose is to expose the relevant imports and other
+ * functions as an object on the DOM, so that the renderer process can invoke
+ * functions that live in the main (Node.js) process if needed.
+ *
+ * Ref: https://www.electronjs.org/docs/latest/tutorial/tutorial-preload
+ *
+ * Note that this script cannot import other code from `src/` - conceptually it
+ * can be thought of as running in a separate, third, process different from
+ * both the main or a renderer process (technically, it runs in a BrowserWindow
+ * context that runs prior to the renderer process).
+ *
+ * > Since enabling the sandbox disables Node.js integration in your preload
+ * > scripts, you can no longer use require("../my-script"). In other words,
+ * > your preload script needs to be a single file.
+ * >
+ * > https://www.electronjs.org/blog/breach-to-barrier
+ *
+ * If we really wanted, we could setup a bundler to package this into a single
+ * file. However, since this is just boilerplate code providing a bridge between
+ * the main and renderer, we avoid introducing another moving part into the mix
+ * and just keep the entire preload setup in this single file.
+ */
+
+import { contextBridge, ipcRenderer } from "electron";
+import { createWriteStream, existsSync } from "node:fs";
+import * as fs from "node:fs/promises";
+import { Readable } from "node:stream";
+import path from "path";
 import { runFFmpegCmd } from "./api/ffmpeg";
-import {
-    deleteFile,
-    deleteFolder,
-    getDirFiles,
-    isFolder,
-    moveFile,
-    readTextFile,
-    rename,
-} from "./api/fs";
+import { getDirFiles } from "./api/fs";
 import { convertToJPEG, generateImageThumbnail } from "./api/imageProcessor";
 import { getEncryptionKey, setEncryptionKey } from "./api/safeStorage";
 import {
-    muteUpdateNotification,
     registerForegroundEventListener,
     registerUpdateEventListener,
-    reloadWindow,
-    sendNotification,
-    skipAppUpdate,
-    updateAndRestart,
 } from "./api/system";
 import {
     getElectronFilesFromGoogleZip,
@@ -58,26 +56,387 @@ import {
     updateWatchMappingIgnoredFiles,
     updateWatchMappingSyncedFiles,
 } from "./api/watch";
-import { setupLogging } from "./utils/logging";
-import {
-    logRendererProcessMemoryUsage,
-    setupRendererProcessStatsLogger,
-} from "./utils/processStats";
+import { logErrorSentry, setupLogging } from "./main/log";
 
 setupLogging();
-setupRendererProcessStatsLogger();
 
-const windowObject: any = window;
+// - General
+
+/** Return the version of the desktop app. */
+const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion");
+
+/**
+ * Open the given {@link dirPath} in the system's folder viewer.
+ *
+ * For example, on macOS this'll open {@link dirPath} in Finder.
+ */
+const openDirectory = (dirPath: string): Promise<void> =>
+    ipcRenderer.invoke("openDirectory");
+
+/**
+ * Open the app's log directory in the system's folder viewer.
+ *
+ * @see {@link openDirectory}
+ */
+const openLogDirectory = (): Promise<void> =>
+    ipcRenderer.invoke("openLogDirectory");
+
+/**
+ * Log the given {@link message} to the on-disk log file maintained by the
+ * desktop app.
+ */
+const logToDisk = (message: string): void =>
+    ipcRenderer.send("logToDisk", message);
+
+// - FIXME below this
+
+/* preload: duplicated logError */
+const logError = (error: Error, message: string, info?: any) => {
+    logErrorSentry(error, message, info);
+};
+
+/* preload: duplicated writeStream */
+/**
+ * Write a (web) ReadableStream to a file at the given {@link filePath}.
+ *
+ * The returned promise resolves when the write completes.
+ *
+ * @param filePath The local filesystem path where the file should be written.
+ * @param readableStream A [web
+ * ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
+ */
+const writeStream = (filePath: string, readableStream: ReadableStream) =>
+    writeNodeStream(filePath, convertWebReadableStreamToNode(readableStream));
+
+/**
+ * Convert a Web ReadableStream into a Node.js ReadableStream
+ *
+ * This can be used to, for example, write a ReadableStream obtained via
+ * `net.fetch` into a file using the Node.js `fs` APIs
+ */
+const convertWebReadableStreamToNode = (readableStream: ReadableStream) => {
+    const reader = readableStream.getReader();
+    const rs = new Readable();
+
+    rs._read = async () => {
+        try {
+            const result = await reader.read();
+
+            if (!result.done) {
+                rs.push(Buffer.from(result.value));
+            } else {
+                rs.push(null);
+                return;
+            }
+        } catch (e) {
+            rs.emit("error", e);
+        }
+    };
+
+    return rs;
+};
+
+const writeNodeStream = async (
+    filePath: string,
+    fileStream: NodeJS.ReadableStream,
+) => {
+    const writeable = createWriteStream(filePath);
+
+    fileStream.on("error", (error) => {
+        writeable.destroy(error); // Close the writable stream with an error
+    });
+
+    fileStream.pipe(writeable);
+
+    await new Promise((resolve, reject) => {
+        writeable.on("finish", resolve);
+        writeable.on("error", async (e: unknown) => {
+            if (existsSync(filePath)) {
+                await fs.unlink(filePath);
+            }
+            reject(e);
+        });
+    });
+};
+
+// - Export
+
+const exists = (path: string) => existsSync(path);
+
+const checkExistsAndCreateDir = (dirPath: string) =>
+    fs.mkdir(dirPath, { recursive: true });
+
+const saveStreamToDisk = writeStream;
+
+const saveFileToDisk = (path: string, contents: string) =>
+    fs.writeFile(path, contents);
+
+// -
+
+async function readTextFile(filePath: string) {
+    if (!existsSync(filePath)) {
+        throw new Error("File does not exist");
+    }
+    return await fs.readFile(filePath, "utf-8");
+}
+
+async function moveFile(
+    sourcePath: string,
+    destinationPath: string,
+): Promise<void> {
+    if (!existsSync(sourcePath)) {
+        throw new Error("File does not exist");
+    }
+    if (existsSync(destinationPath)) {
+        throw new Error("Destination file already exists");
+    }
+    // check if destination folder exists
+    const destinationFolder = path.dirname(destinationPath);
+    await fs.mkdir(destinationFolder, { recursive: true });
+    await fs.rename(sourcePath, destinationPath);
+}
 
-windowObject["ElectronAPIs"] = {
+export async function isFolder(dirPath: string) {
+    try {
+        const stats = await fs.stat(dirPath);
+        return stats.isDirectory();
+    } catch (e) {
+        let err = e;
+        // if code is defined, it's an error from fs.stat
+        if (typeof e.code !== "undefined") {
+            // ENOENT means the file does not exist
+            if (e.code === "ENOENT") {
+                return false;
+            }
+            err = Error(`fs error code: ${e.code}`);
+        }
+        logError(err, "isFolder failed");
+        return false;
+    }
+}
+
+async function deleteFolder(folderPath: string): Promise<void> {
+    if (!existsSync(folderPath)) {
+        return;
+    }
+    const stat = await fs.stat(folderPath);
+    if (!stat.isDirectory()) {
+        throw new Error("Path is not a folder");
+    }
+    // check if folder is empty
+    const files = await fs.readdir(folderPath);
+    if (files.length > 0) {
+        throw new Error("Folder is not empty");
+    }
+    await fs.rmdir(folderPath);
+}
+
+async function rename(oldPath: string, newPath: string) {
+    if (!existsSync(oldPath)) {
+        throw new Error("Path does not exist");
+    }
+    await fs.rename(oldPath, newPath);
+}
+
+const deleteFile = async (filePath: string) => {
+    if (!existsSync(filePath)) {
+        return;
+    }
+    const stat = await fs.stat(filePath);
+    if (!stat.isFile()) {
+        throw new Error("Path is not a file");
+    }
+    return fs.rm(filePath);
+};
+
+// - ML
+
+/* preload: duplicated Model */
+export enum Model {
+    GGML_CLIP = "ggml-clip",
+    ONNX_CLIP = "onnx-clip",
+}
+
+const computeImageEmbedding = async (
+    model: Model,
+    imageData: Uint8Array,
+): Promise<Float32Array> => {
+    let tempInputFilePath = null;
+    try {
+        tempInputFilePath = await ipcRenderer.invoke("get-temp-file-path", "");
+        const imageStream = new Response(imageData.buffer).body;
+        await writeStream(tempInputFilePath, imageStream);
+        const embedding = await ipcRenderer.invoke(
+            "compute-image-embedding",
+            model,
+            tempInputFilePath,
+        );
+        return embedding;
+    } catch (err) {
+        if (isExecError(err)) {
+            const parsedExecError = parseExecError(err);
+            throw Error(parsedExecError);
+        } else {
+            throw err;
+        }
+    } finally {
+        if (tempInputFilePath) {
+            await ipcRenderer.invoke("remove-temp-file", tempInputFilePath);
+        }
+    }
+};
+
+export async function computeTextEmbedding(
+    model: Model,
+    text: string,
+): Promise<Float32Array> {
+    try {
+        const embedding = await ipcRenderer.invoke(
+            "compute-text-embedding",
+            model,
+            text,
+        );
+        return embedding;
+    } catch (err) {
+        if (isExecError(err)) {
+            const parsedExecError = parseExecError(err);
+            throw Error(parsedExecError);
+        } else {
+            throw err;
+        }
+    }
+}
+
+// -
+
+/**
+ * [Note: Custom errors across Electron/Renderer boundary]
+ *
+ * We need to use the `message` field to disambiguate between errors thrown by
+ * the main process when invoked from the renderer process. This is because:
+ *
+ * > Errors thrown throw `handle` in the main process are not transparent as
+ * > they are serialized and only the `message` property from the original error
+ * > is provided to the renderer process.
+ * >
+ * > - https://www.electronjs.org/docs/latest/tutorial/ipc
+ * >
+ * > Ref: https://github.com/electron/electron/issues/24427
+ */
+/* preload: duplicated CustomErrors */
+const CustomErrorsP = {
+    WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED:
+        "Windows native image processing is not supported",
+    INVALID_OS: (os: string) => `Invalid OS - ${os}`,
+    WAIT_TIME_EXCEEDED: "Wait time exceeded",
+    UNSUPPORTED_PLATFORM: (platform: string, arch: string) =>
+        `Unsupported platform - ${platform} ${arch}`,
+    MODEL_DOWNLOAD_PENDING:
+        "Model download pending, skipping clip search request",
+    INVALID_FILE_PATH: "Invalid file path",
+    INVALID_CLIP_MODEL: (model: string) => `Invalid Clip model - ${model}`,
+};
+
+const isExecError = (err: any) => {
+    return err.message.includes("Command failed:");
+};
+
+const parseExecError = (err: any) => {
+    const errMessage = err.message;
+    if (errMessage.includes("Bad CPU type in executable")) {
+        return CustomErrorsP.UNSUPPORTED_PLATFORM(
+            process.platform,
+            process.arch,
+        );
+    } else {
+        return errMessage;
+    }
+};
+
+// - General
+
+const selectDirectory = async (): Promise<string> => {
+    try {
+        return await ipcRenderer.invoke("select-dir");
+    } catch (e) {
+        logError(e, "error while selecting root directory");
+    }
+};
+
+const clearElectronStore = () => {
+    ipcRenderer.send("clear-electron-store");
+};
+
+// - App update
+
+const updateAndRestart = () => {
+    ipcRenderer.send("update-and-restart");
+};
+
+const skipAppUpdate = (version: string) => {
+    ipcRenderer.send("skip-app-update", version);
+};
+
+const muteUpdateNotification = (version: string) => {
+    ipcRenderer.send("mute-update-notification", version);
+};
+
+// -
+
+// These objects exposed here will become available to the JS code in our
+// renderer (the web/ code) as `window.ElectronAPIs.*`
+//
+// There are a few related concepts at play here, and it might be worthwhile to
+// read their (excellent) documentation to get an understanding;
+//`
+// - ContextIsolation:
+//   https://www.electronjs.org/docs/latest/tutorial/context-isolation
+//
+// - IPC https://www.electronjs.org/docs/latest/tutorial/ipc
+//
+// [Note: Transferring large amount of data over IPC]
+//
+// Electron's IPC implementation uses the HTML standard Structured Clone
+// Algorithm to serialize objects passed between processes.
+// https://www.electronjs.org/docs/latest/tutorial/ipc#object-serialization
+//
+// In particular, both ArrayBuffer is eligible for structured cloning.
+// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
+//
+// Also, ArrayBuffer is "transferable", which means it is a zero-copy operation
+// operation when it happens across threads.
+// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
+//
+// In our case though, we're not dealing with threads but separate processes. So
+// the ArrayBuffer will be copied:
+// > "parameters, errors and return values are **copied** when they're sent over
+//   the bridge".
+//   https://www.electronjs.org/docs/latest/api/context-bridge#methods
+//
+// The copy itself is relatively fast, but the problem with transfering large
+// amounts of data is potentially running out of memory during the copy.
+contextBridge.exposeInMainWorld("ElectronAPIs", {
+    // General
+    appVersion,
+    openDirectory,
+
+    // Logging
+    openLogDirectory,
+    logToDisk,
+
+    // - App update
+    updateAndRestart,
+    skipAppUpdate,
+    muteUpdateNotification,
+
+    // - Export
     exists,
     checkExistsAndCreateDir,
     saveStreamToDisk,
     saveFileToDisk,
+
     selectDirectory,
     clearElectronStore,
-    sendNotification,
-    reloadWindow,
     readTextFile,
     showUploadFilesDialog,
     showUploadDirsDialog,
@@ -88,8 +447,6 @@ windowObject["ElectronAPIs"] = {
     setToUploadCollection,
     getEncryptionKey,
     setEncryptionKey,
-    openDiskCache,
-    deleteDiskCache,
     getDirFiles,
     getWatchMappings,
     addWatchMapping,
@@ -98,26 +455,18 @@ windowObject["ElectronAPIs"] = {
     isFolder,
     updateWatchMappingSyncedFiles,
     updateWatchMappingIgnoredFiles,
-    logToDisk,
     convertToJPEG,
-    openLogDirectory,
     registerUpdateEventListener,
-    updateAndRestart,
-    skipAppUpdate,
-    getAppVersion,
+
     runFFmpegCmd,
-    muteUpdateNotification,
     generateImageThumbnail,
-    logRendererProcessMemoryUsage,
     registerForegroundEventListener,
-    openDirectory,
     moveFile,
     deleteFolder,
     rename,
     deleteFile,
+
+    // - ML
     computeImageEmbedding,
     computeTextEmbedding,
-    getPlatform,
-    getCacheDirectory,
-    setCustomCacheDirectory,
-};
+});

+ 38 - 63
desktop/src/services/appUpdater.ts

@@ -2,11 +2,9 @@ import { compareVersions } from "compare-versions";
 import { app, BrowserWindow } from "electron";
 import { default as ElectronLog, default as log } from "electron-log";
 import { autoUpdater } from "electron-updater";
-import fetch from "node-fetch";
 import { setIsAppQuitting, setIsUpdateAvailable } from "../main";
-import { AppUpdateInfo, GetFeatureFlagResponse } from "../types";
-import { isPlatform } from "../utils/common/platform";
-import { logErrorSentry } from "./sentry";
+import { logErrorSentry } from "../main/log";
+import { AppUpdateInfo } from "../types";
 import {
     clearMuteUpdateNotificationVersion,
     clearSkipAppVersion,
@@ -64,56 +62,42 @@ async function checkForUpdateAndNotify(mainWindow: BrowserWindow) {
             );
             return;
         }
-        const desktopCutoffVersion = await getDesktopCutoffVersion();
+
+        let timeout: NodeJS.Timeout;
+        log.debug("attempting auto update");
+        autoUpdater.downloadUpdate();
+        const muteUpdateNotificationVersion =
+            getMuteUpdateNotificationVersion();
         if (
-            desktopCutoffVersion &&
-            isPlatform("mac") &&
-            compareVersions(
-                updateCheckResult.updateInfo.version,
-                desktopCutoffVersion,
-            ) > 0
+            muteUpdateNotificationVersion &&
+            updateCheckResult.updateInfo.version ===
+                muteUpdateNotificationVersion
         ) {
-            log.debug("auto update not possible due to key change");
+            log.info(
+                "user chose to mute update notification for version ",
+                updateCheckResult.updateInfo.version,
+            );
+            return;
+        }
+        autoUpdater.on("update-downloaded", () => {
+            timeout = setTimeout(
+                () =>
+                    showUpdateDialog(mainWindow, {
+                        autoUpdatable: true,
+                        version: updateCheckResult.updateInfo.version,
+                    }),
+                FIVE_MIN_IN_MICROSECOND,
+            );
+        });
+        autoUpdater.on("error", (error) => {
+            clearTimeout(timeout);
+            logErrorSentry(error, "auto update failed");
             showUpdateDialog(mainWindow, {
                 autoUpdatable: false,
                 version: updateCheckResult.updateInfo.version,
             });
-        } else {
-            let timeout: NodeJS.Timeout;
-            log.debug("attempting auto update");
-            autoUpdater.downloadUpdate();
-            const muteUpdateNotificationVersion =
-                getMuteUpdateNotificationVersion();
-            if (
-                muteUpdateNotificationVersion &&
-                updateCheckResult.updateInfo.version ===
-                    muteUpdateNotificationVersion
-            ) {
-                log.info(
-                    "user chose to mute update notification for version ",
-                    updateCheckResult.updateInfo.version,
-                );
-                return;
-            }
-            autoUpdater.on("update-downloaded", () => {
-                timeout = setTimeout(
-                    () =>
-                        showUpdateDialog(mainWindow, {
-                            autoUpdatable: true,
-                            version: updateCheckResult.updateInfo.version,
-                        }),
-                    FIVE_MIN_IN_MICROSECOND,
-                );
-            });
-            autoUpdater.on("error", (error) => {
-                clearTimeout(timeout);
-                logErrorSentry(error, "auto update failed");
-                showUpdateDialog(mainWindow, {
-                    autoUpdatable: false,
-                    version: updateCheckResult.updateInfo.version,
-                });
-            });
-        }
+        });
+
         setIsUpdateAvailable(true);
     } catch (e) {
         logErrorSentry(e, "checkForUpdateAndNotify failed");
@@ -126,9 +110,12 @@ export function updateAndRestart() {
     autoUpdater.quitAndInstall();
 }
 
-export function getAppVersion() {
-    return `v${app.getVersion()}`;
-}
+/**
+ * Return the version of the desktop app
+ *
+ * The return value is of the form `v1.2.3`.
+ */
+export const appVersion = () => `v${app.getVersion()}`;
 
 export function skipAppUpdate(version: string) {
     setSkipAppVersion(version);
@@ -138,18 +125,6 @@ export function muteUpdateNotification(version: string) {
     setMuteUpdateNotificationVersion(version);
 }
 
-async function getDesktopCutoffVersion() {
-    try {
-        const featureFlags = (
-            await fetch("https://static.ente.io/feature_flags.json")
-        ).json() as GetFeatureFlagResponse;
-        return featureFlags.desktopCutoffVersion;
-    } catch (e) {
-        logErrorSentry(e, "failed to get feature flags");
-        return undefined;
-    }
-}
-
 function showUpdateDialog(
     mainWindow: BrowserWindow,
     updateInfo: AppUpdateInfo,

+ 1 - 1
desktop/src/services/chokidar.ts

@@ -1,7 +1,7 @@
 import chokidar from "chokidar";
 import { BrowserWindow } from "electron";
 import { getWatchMappings } from "../api/watch";
-import { logError } from "../services/logging";
+import { logError } from "../main/log";
 
 export function initWatcher(mainWindow: BrowserWindow) {
     const mappings = getWatchMappings();

+ 14 - 26
desktop/src/services/clipService.ts

@@ -1,18 +1,16 @@
-import { app } from "electron";
 import * as log from "electron-log";
+import { app, net } from "electron/main";
 import { existsSync } from "fs";
-import fs from "fs/promises";
-import fetch from "node-fetch";
-import path from "path";
-import { readFile } from "promise-fs";
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
 import util from "util";
 import { CustomErrors } from "../constants/errors";
 import { Model } from "../types";
 import Tokenizer from "../utils/clip-bpe-ts/mod";
-import { isDev } from "../utils/common";
+import { isDev } from "../main/general";
 import { getPlatform } from "../utils/common/platform";
-import { writeNodeStream } from "./fs";
-import { logErrorSentry } from "./sentry";
+import { writeStream } from "./fs";
+import { logErrorSentry } from "../main/log";
 const shellescape = require("any-shell-escape");
 const execAsync = util.promisify(require("child_process").exec);
 const jpeg = require("jpeg-js");
@@ -65,28 +63,18 @@ const TEXT_MODEL_SIZE_IN_BYTES = {
     onnx: 64173509, // 61.2 MB
 };
 
-const MODEL_SAVE_FOLDER = "models";
-
-function getModelSavePath(modelName: string) {
-    let userDataDir: string;
-    if (isDev) {
-        userDataDir = ".";
-    } else {
-        userDataDir = app.getPath("userData");
-    }
-    return path.join(userDataDir, MODEL_SAVE_FOLDER, modelName);
-}
+/** Return the path where the given {@link modelName} is meant to be saved */
+const getModelSavePath = (modelName: string) =>
+    path.join(app.getPath("userData"), "models", modelName);
 
 async function downloadModel(saveLocation: string, url: string) {
     // confirm that the save location exists
     const saveDir = path.dirname(saveLocation);
-    if (!existsSync(saveDir)) {
-        log.info("creating model save dir");
-        await fs.mkdir(saveDir, { recursive: true });
-    }
+    await fs.mkdir(saveDir, { recursive: true });
     log.info("downloading clip model");
-    const resp = await fetch(url);
-    await writeNodeStream(saveLocation, resp.body);
+    const res = await net.fetch(url);
+    if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
+    await writeStream(saveLocation, res.body);
     log.info("clip model downloaded");
 }
 
@@ -369,7 +357,7 @@ export async function computeONNXTextEmbedding(
 }
 
 async function getRGBData(inputFilePath: string) {
-    const jpegData = await readFile(inputFilePath);
+    const jpegData = await fs.readFile(inputFilePath);
     let rawImageData;
     try {
         rawImageData = jpeg.decode(jpegData, {

+ 0 - 98
desktop/src/services/diskCache.ts

@@ -1,98 +0,0 @@
-import crypto from "crypto";
-import path from "path";
-import { existsSync, rename, stat, unlink } from "promise-fs";
-import DiskLRUService from "../services/diskLRU";
-import { LimitedCache } from "../types/cache";
-import { getFileStream, writeStream } from "./fs";
-import { logError } from "./logging";
-
-const DEFAULT_CACHE_LIMIT = 1000 * 1000 * 1000; // 1GB
-
-export class DiskCache implements LimitedCache {
-    constructor(
-        private cacheBucketDir: string,
-        private cacheLimit = DEFAULT_CACHE_LIMIT,
-    ) {}
-
-    async put(cacheKey: string, response: Response): Promise<void> {
-        const cachePath = path.join(this.cacheBucketDir, cacheKey);
-        await writeStream(cachePath, response.body);
-        DiskLRUService.enforceCacheSizeLimit(
-            this.cacheBucketDir,
-            this.cacheLimit,
-        );
-    }
-
-    async match(
-        cacheKey: string,
-        { sizeInBytes }: { sizeInBytes?: number } = {},
-    ): Promise<Response> {
-        const cachePath = path.join(this.cacheBucketDir, cacheKey);
-        if (existsSync(cachePath)) {
-            const fileStats = await stat(cachePath);
-            if (sizeInBytes && fileStats.size !== sizeInBytes) {
-                logError(
-                    Error(),
-                    "Cache key exists but size does not match. Deleting cache key.",
-                );
-                unlink(cachePath).catch((e) => {
-                    if (e.code === "ENOENT") return;
-                    logError(e, "Failed to delete cache key");
-                });
-                return undefined;
-            }
-            DiskLRUService.touch(cachePath);
-            return new Response(await getFileStream(cachePath));
-        } else {
-            // add fallback for old cache keys
-            const oldCachePath = getOldAssetCachePath(
-                this.cacheBucketDir,
-                cacheKey,
-            );
-            if (existsSync(oldCachePath)) {
-                const fileStats = await stat(oldCachePath);
-                if (sizeInBytes && fileStats.size !== sizeInBytes) {
-                    logError(
-                        Error(),
-                        "Old cache key exists but size does not match. Deleting cache key.",
-                    );
-                    unlink(oldCachePath).catch((e) => {
-                        if (e.code === "ENOENT") return;
-                        logError(e, "Failed to delete cache key");
-                    });
-                    return undefined;
-                }
-                const match = new Response(await getFileStream(oldCachePath));
-                void migrateOldCacheKey(oldCachePath, cachePath);
-                return match;
-            }
-            return undefined;
-        }
-    }
-    async delete(cacheKey: string): Promise<boolean> {
-        const cachePath = path.join(this.cacheBucketDir, cacheKey);
-        if (existsSync(cachePath)) {
-            await unlink(cachePath);
-            return true;
-        } else {
-            return false;
-        }
-    }
-}
-
-function getOldAssetCachePath(cacheDir: string, cacheKey: string) {
-    // hashing the key to prevent illegal filenames
-    const cacheKeyHash = crypto
-        .createHash("sha256")
-        .update(cacheKey)
-        .digest("hex");
-    return path.join(cacheDir, cacheKeyHash);
-}
-
-async function migrateOldCacheKey(oldCacheKey: string, newCacheKey: string) {
-    try {
-        await rename(oldCacheKey, newCacheKey);
-    } catch (e) {
-        logError(e, "Failed to move cache key to new cache key");
-    }
-}

+ 0 - 105
desktop/src/services/diskLRU.ts

@@ -1,105 +0,0 @@
-import getFolderSize from "get-folder-size";
-import path from "path";
-import { close, open, readdir, stat, unlink, utimes } from "promise-fs";
-import { logError } from "../services/logging";
-
-export interface LeastRecentlyUsedResult {
-    atime: Date;
-    path: string;
-}
-
-class DiskLRUService {
-    private isRunning: Promise<any> = null;
-    private reRun: boolean = false;
-
-    async touch(path: string) {
-        try {
-            const time = new Date();
-            await utimes(path, time, time);
-        } catch (err) {
-            logError(err, "utimes method touch failed");
-            try {
-                await close(await open(path, "w"));
-            } catch (e) {
-                logError(e, "open-close method touch failed");
-            }
-            // log and ignore
-        }
-    }
-
-    enforceCacheSizeLimit(cacheDir: string, maxSize: number) {
-        if (!this.isRunning) {
-            this.isRunning = this.evictLeastRecentlyUsed(cacheDir, maxSize);
-            this.isRunning.then(() => {
-                this.isRunning = null;
-                if (this.reRun) {
-                    this.reRun = false;
-                    this.enforceCacheSizeLimit(cacheDir, maxSize);
-                }
-            });
-        } else {
-            this.reRun = true;
-        }
-    }
-
-    async evictLeastRecentlyUsed(cacheDir: string, maxSize: number) {
-        try {
-            await new Promise((resolve) => {
-                getFolderSize(cacheDir, async (err, size) => {
-                    if (err) {
-                        throw err;
-                    }
-                    if (size >= maxSize) {
-                        const leastRecentlyUsed =
-                            await this.findLeastRecentlyUsed(cacheDir);
-                        try {
-                            await unlink(leastRecentlyUsed.path);
-                        } catch (e) {
-                            // ENOENT: File not found
-                            // which can be ignored as we are trying to delete the file anyway
-                            if (e.code !== "ENOENT") {
-                                logError(
-                                    e,
-                                    "Failed to evict least recently used",
-                                );
-                            }
-                            // ignoring the error, as it would get retried on the next run
-                        }
-                        this.evictLeastRecentlyUsed(cacheDir, maxSize);
-                    }
-                    resolve(null);
-                });
-            });
-        } catch (e) {
-            logError(e, "evictLeastRecentlyUsed failed");
-        }
-    }
-
-    private async findLeastRecentlyUsed(
-        dir: string,
-        result?: LeastRecentlyUsedResult,
-    ): Promise<LeastRecentlyUsedResult> {
-        result = result || { atime: new Date(), path: "" };
-
-        const files = await readdir(dir);
-        for (const file of files) {
-            const newBase = path.join(dir, file);
-            const stats = await stat(newBase);
-            if (stats.isDirectory()) {
-                result = await this.findLeastRecentlyUsed(newBase, result);
-            } else {
-                const { atime } = await stat(newBase);
-
-                if (atime.getTime() < result.atime.getTime()) {
-                    result = {
-                        atime,
-                        path: newBase,
-                    };
-                }
-            }
-        }
-        return result;
-    }
-}
-
-export default new DiskLRUService();

+ 74 - 19
desktop/src/services/ffmpeg.ts

@@ -1,25 +1,46 @@
 import log from "electron-log";
 import pathToFfmpeg from "ffmpeg-static";
-import { existsSync } from "fs";
-import { readFile, rmSync, writeFile } from "promise-fs";
+import { existsSync } from "node:fs";
+import * as fs from "node:fs/promises";
 import util from "util";
-import { promiseWithTimeout } from "../utils/common";
+import { CustomErrors } from "../constants/errors";
 import { generateTempFilePath, getTempDirPath } from "../utils/temp";
-import { logErrorSentry } from "./sentry";
+import { logErrorSentry } from "../main/log";
 const shellescape = require("any-shell-escape");
 
 const execAsync = util.promisify(require("child_process").exec);
 
-const FFMPEG_EXECUTION_WAIT_TIME = 30 * 1000;
-
 const INPUT_PATH_PLACEHOLDER = "INPUT";
 const FFMPEG_PLACEHOLDER = "FFMPEG";
 const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
 
-function getFFmpegStaticPath() {
-    return pathToFfmpeg.replace("app.asar", "app.asar.unpacked");
-}
-
+/**
+ * Run a ffmpeg command
+ *
+ * [Note: FFMPEG in Electron]
+ *
+ * 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.
+ * https://ffmpegwasm.netlify.app/docs/performance
+ *
+ * So the alternative is to bundle a ffmpeg binary with our app. e.g.
+ *
+ *     yarn add fluent-ffmpeg ffmpeg-static ffprobe-static
+ *
+ * (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).
+ * https://chromium.googlesource.com/chromium/third_party/ffmpeg
+ * https://stackoverflow.com/questions/53963672/what-version-of-ffmpeg-is-bundled-inside-electron
+ *
+ * This can be found in (e.g. on macOS) at
+ *
+ *     $ file ente.app/Contents/Frameworks/Electron\ Framework.framework/Versions/Current/Libraries/libffmpeg.dylib
+ *     .../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.
+ */
 export async function runFFmpegCmd(
     cmd: string[],
     inputFilePath: string,
@@ -32,7 +53,7 @@ export async function runFFmpegCmd(
 
         cmd = cmd.map((cmdPart) => {
             if (cmdPart === FFMPEG_PLACEHOLDER) {
-                return getFFmpegStaticPath();
+                return ffmpegBinaryPath();
             } else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
                 return inputFilePath;
             } else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
@@ -47,10 +68,7 @@ export async function runFFmpegCmd(
         if (dontTimeout) {
             await execAsync(escapedCmd);
         } else {
-            await promiseWithTimeout(
-                execAsync(escapedCmd),
-                FFMPEG_EXECUTION_WAIT_TIME,
-            );
+            await promiseWithTimeout(execAsync(escapedCmd), 30 * 1000);
         }
         if (!existsSync(tempOutputFilePath)) {
             throw new Error("ffmpeg output file not found");
@@ -62,23 +80,36 @@ export async function runFFmpegCmd(
             "ms",
         );
 
-        const outputFile = await readFile(tempOutputFilePath);
+        const outputFile = await fs.readFile(tempOutputFilePath);
         return new Uint8Array(outputFile);
     } catch (e) {
         logErrorSentry(e, "ffmpeg run command error");
         throw e;
     } finally {
         try {
-            rmSync(tempOutputFilePath, { force: true });
+            await fs.rm(tempOutputFilePath, { force: true });
         } catch (e) {
             logErrorSentry(e, "failed to remove tempOutputFile");
         }
     }
 }
 
+/**
+ * Return the path to the `ffmpeg` binary.
+ *
+ * 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`
+ */
+const ffmpegBinaryPath = () => {
+    // This substitution of app.asar by app.asar.unpacked is suggested by the
+    // ffmpeg-static library author themselves:
+    // 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 writeFile(tempFilePath, fileStream);
+    await fs.writeFile(tempFilePath, fileStream);
     return tempFilePath;
 }
 
@@ -90,5 +121,29 @@ export async function deleteTempFile(tempFilePath: string) {
             "tried to delete a non temp file",
         );
     }
-    rmSync(tempFilePath, { force: true });
+    await fs.rm(tempFilePath, { force: true });
 }
+
+export 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(Error(CustomErrors.WAIT_TIME_EXCEEDED)),
+            timeout,
+        );
+    });
+    const requestWithTimeOutCancellation = async () => {
+        const resp = await request;
+        clearTimeout(timeoutRef.current);
+        return resp;
+    };
+    return await Promise.race([
+        requestWithTimeOutCancellation(),
+        rejectOnTimeout,
+    ]);
+};

+ 11 - 89
desktop/src/services/fs.ts

@@ -1,10 +1,10 @@
-import { existsSync } from "fs";
 import StreamZip from "node-stream-zip";
-import path from "path";
-import * as fs from "promise-fs";
+import { createWriteStream, existsSync } from "node:fs";
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
 import { Readable } from "stream";
 import { ElectronFile } from "../types";
-import { logError } from "./logging";
+import { logError } from "../main/log";
 
 const FILE_STREAM_CHUNK_SIZE: number = 4 * 1024 * 1024;
 
@@ -25,16 +25,14 @@ export const getDirFilePaths = async (dirPath: string) => {
     return files;
 };
 
-export const getFileStream = async (filePath: string) => {
+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);
-                // original types were not working correctly
-                const bytesRead = (await fs.read(
-                    file,
+                const bytesRead = (await file.read(
                     buff,
                     0,
                     FILE_STREAM_CHUNK_SIZE,
@@ -43,16 +41,16 @@ export const getFileStream = async (filePath: string) => {
                 offset += bytesRead;
                 if (bytesRead === 0) {
                     controller.close();
-                    await fs.close(file);
+                    await file.close();
                 } else {
                     controller.enqueue(buff.slice(0, bytesRead));
                 }
             } catch (e) {
-                await fs.close(file);
+                await file.close();
             }
         },
         async cancel() {
-            await fs.close(file);
+            await file.close();
         },
     });
     return readableStream;
@@ -184,25 +182,6 @@ export const getZipFileStream = async (
     return readableStream;
 };
 
-export async function isFolder(dirPath: string) {
-    try {
-        const stats = await fs.stat(dirPath);
-        return stats.isDirectory();
-    } catch (e) {
-        let err = e;
-        // if code is defined, it's an error from fs.stat
-        if (typeof e.code !== "undefined") {
-            // ENOENT means the file does not exist
-            if (e.code === "ENOENT") {
-                return false;
-            }
-            err = Error(`fs error code: ${e.code}`);
-        }
-        logError(err, "isFolder failed");
-        return false;
-    }
-}
-
 export const convertBrowserStreamToNode = (
     fileStream: ReadableStream<Uint8Array>,
 ) => {
@@ -231,7 +210,7 @@ export async function writeNodeStream(
     filePath: string,
     fileStream: NodeJS.ReadableStream,
 ) {
-    const writeable = fs.createWriteStream(filePath);
+    const writeable = createWriteStream(filePath);
 
     fileStream.on("error", (error) => {
         writeable.destroy(error); // Close the writable stream with an error
@@ -241,7 +220,7 @@ export async function writeNodeStream(
 
     await new Promise((resolve, reject) => {
         writeable.on("finish", resolve);
-        writeable.on("error", async (e) => {
+        writeable.on("error", async (e: unknown) => {
             if (existsSync(filePath)) {
                 await fs.unlink(filePath);
             }
@@ -257,60 +236,3 @@ export async function writeStream(
     const readable = convertBrowserStreamToNode(fileStream);
     await writeNodeStream(filePath, readable);
 }
-
-export async function readTextFile(filePath: string) {
-    if (!existsSync(filePath)) {
-        throw new Error("File does not exist");
-    }
-    return await fs.readFile(filePath, "utf-8");
-}
-
-export async function moveFile(
-    sourcePath: string,
-    destinationPath: string,
-): Promise<void> {
-    if (!existsSync(sourcePath)) {
-        throw new Error("File does not exist");
-    }
-    if (existsSync(destinationPath)) {
-        throw new Error("Destination file already exists");
-    }
-    // check if destination folder exists
-    const destinationFolder = path.dirname(destinationPath);
-    if (!existsSync(destinationFolder)) {
-        await fs.mkdir(destinationFolder, { recursive: true });
-    }
-    await fs.rename(sourcePath, destinationPath);
-}
-
-export async function deleteFolder(folderPath: string): Promise<void> {
-    if (!existsSync(folderPath)) {
-        return;
-    }
-    if (!fs.statSync(folderPath).isDirectory()) {
-        throw new Error("Path is not a folder");
-    }
-    // check if folder is empty
-    const files = await fs.readdir(folderPath);
-    if (files.length > 0) {
-        throw new Error("Folder is not empty");
-    }
-    await fs.rmdir(folderPath);
-}
-
-export async function rename(oldPath: string, newPath: string) {
-    if (!existsSync(oldPath)) {
-        throw new Error("Path does not exist");
-    }
-    await fs.rename(oldPath, newPath);
-}
-
-export function deleteFile(filePath: string): void {
-    if (!existsSync(filePath)) {
-        return;
-    }
-    if (!fs.statSync(filePath).isFile()) {
-        throw new Error("Path is not a file");
-    }
-    fs.rmSync(filePath);
-}

+ 10 - 20
desktop/src/services/imageProcessor.ts

@@ -2,14 +2,13 @@ import { exec } from "child_process";
 import util from "util";
 
 import log from "electron-log";
-import { existsSync, rmSync } from "fs";
+import * as fs from "node:fs/promises";
 import path from "path";
-import { readFile, writeFile } from "promise-fs";
 import { CustomErrors } from "../constants/errors";
-import { isDev } from "../utils/common";
+import { isDev } from "../main/general";
 import { isPlatform } from "../utils/common/platform";
 import { generateTempFilePath } from "../utils/temp";
-import { logErrorSentry } from "./sentry";
+import { logErrorSentry } from "../main/log";
 const shellescape = require("any-shell-escape");
 
 const asyncExec = util.promisify(exec);
@@ -59,10 +58,10 @@ const IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE = [
 
 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}`,
-    INPUT_PATH_PLACEHOLDER,
     "-thumbnail",
     `${MAX_DIMENSION_PLACEHOLDER}x${MAX_DIMENSION_PLACEHOLDER}>`,
     "-unsharp",
@@ -88,28 +87,22 @@ export async function convertToJPEG(
         tempInputFilePath = await generateTempFilePath(filename);
         tempOutputFilePath = await generateTempFilePath("output.jpeg");
 
-        await writeFile(tempInputFilePath, fileData);
+        await fs.writeFile(tempInputFilePath, fileData);
 
         await runConvertCommand(tempInputFilePath, tempOutputFilePath);
 
-        if (!existsSync(tempOutputFilePath)) {
-            throw new Error("heic convert output file not found");
-        }
-        const convertedFileData = new Uint8Array(
-            await readFile(tempOutputFilePath),
-        );
-        return convertedFileData;
+        return new Uint8Array(await fs.readFile(tempOutputFilePath));
     } catch (e) {
         logErrorSentry(e, "failed to convert heic");
         throw e;
     } finally {
         try {
-            rmSync(tempInputFilePath, { force: true });
+            await fs.rm(tempInputFilePath, { force: true });
         } catch (e) {
             logErrorSentry(e, "failed to remove tempInputFile");
         }
         try {
-            rmSync(tempOutputFilePath, { force: true });
+            await fs.rm(tempOutputFilePath, { force: true });
         } catch (e) {
             logErrorSentry(e, "failed to remove tempOutputFile");
         }
@@ -183,10 +176,7 @@ export async function generateImageThumbnail(
                 quality,
             );
 
-            if (!existsSync(tempOutputFilePath)) {
-                throw new Error("output thumbnail file not found");
-            }
-            thumbnail = new Uint8Array(await readFile(tempOutputFilePath));
+            thumbnail = new Uint8Array(await fs.readFile(tempOutputFilePath));
             quality -= 10;
         } while (thumbnail.length > maxSize && quality > MIN_QUALITY);
         return thumbnail;
@@ -195,7 +185,7 @@ export async function generateImageThumbnail(
         throw e;
     } finally {
         try {
-            rmSync(tempOutputFilePath, { force: true });
+            await fs.rm(tempOutputFilePath, { force: true });
         } catch (e) {
             logErrorSentry(e, "failed to remove tempOutputFile");
         }

+ 0 - 14
desktop/src/services/logging.ts

@@ -1,14 +0,0 @@
-import { ipcRenderer } from "electron";
-import log from "electron-log";
-
-export function logToDisk(logLine: string) {
-    log.info(logLine);
-}
-
-export function openLogDirectory() {
-    ipcRenderer.invoke("open-log-dir");
-}
-
-export function logError(error: Error, message: string, info?: string): void {
-    ipcRenderer.invoke("log-error", error, message, info);
-}

+ 0 - 18
desktop/src/services/sentry.ts

@@ -1,18 +0,0 @@
-import { isDev } from "../utils/common";
-import { logToDisk } from "./logging";
-
-/** Deprecated, but no alternative yet */
-export function logErrorSentry(
-    error: any,
-    msg: string,
-    info?: Record<string, unknown>,
-) {
-    logToDisk(
-        `error: ${error?.name} ${error?.message} ${
-            error?.stack
-        } msg: ${msg} info: ${JSON.stringify(info)}`,
-    );
-    if (isDev) {
-        console.log(error, { msg, info });
-    }
-}

+ 0 - 8
desktop/src/services/userPreference.ts

@@ -31,11 +31,3 @@ export function clearSkipAppVersion() {
 export function clearMuteUpdateNotificationVersion() {
     userPreferencesStore.delete("muteUpdateNotificationVersion");
 }
-
-export function setCustomCacheDirectory(directory: string) {
-    userPreferencesStore.set("customCacheDirectory", directory);
-}
-
-export function getCustomCacheDirectory(): string {
-    return userPreferencesStore.get("customCacheDirectory");
-}

+ 0 - 3
desktop/src/stores/userPreferences.store.ts

@@ -11,9 +11,6 @@ const userPreferencesSchema: Schema<UserPreferencesType> = {
     muteUpdateNotificationVersion: {
         type: "string",
     },
-    customCacheDirectory: {
-        type: "string",
-    },
 };
 
 export const userPreferencesStore = new Store({

+ 0 - 8
desktop/src/types/cache.ts

@@ -1,8 +0,0 @@
-export interface LimitedCache {
-    match: (
-        key: string,
-        options?: { sizeInBytes?: number },
-    ) => Promise<Response>;
-    put: (key: string, data: Response) => Promise<void>;
-    delete: (key: string) => Promise<boolean>;
-}

+ 12 - 5
desktop/src/types/index.ts

@@ -1,3 +1,15 @@
+/**
+ * Deprecated - Use File + webUtils.getPathForFile instead
+ *
+ * Electron used to augment the standard web
+ * [File](https://developer.mozilla.org/en-US/docs/Web/API/File) object with an
+ * additional `path` property. This is now deprecated, and will be removed in a
+ * future release.
+ * https://www.electronjs.org/docs/latest/api/file-object
+ *
+ * The alternative to the `path` property is to use `webUtils.getPathForFile`
+ * https://www.electronjs.org/docs/latest/api/web-utils
+ */
 export interface ElectronFile {
     name: string;
     path: string;
@@ -58,7 +70,6 @@ export interface UserPreferencesType {
     hideDockIcon: boolean;
     skipAppVersion: string;
     muteUpdateNotificationVersion: string;
-    customCacheDirectory: string;
 }
 
 export interface AppUpdateInfo {
@@ -66,10 +77,6 @@ export interface AppUpdateInfo {
     version: string;
 }
 
-export interface GetFeatureFlagResponse {
-    desktopCutoffVersion?: string;
-}
-
 export enum Model {
     GGML_CLIP = "ggml-clip",
     ONNX_CLIP = "onnx-clip",

+ 18 - 5
desktop/src/utils/clip-bpe-ts/README.md

@@ -1,6 +1,7 @@
 # CLIP Byte Pair Encoding JavaScript Port
 
-A JavaScript port of [OpenAI's CLIP byte-pair-encoding tokenizer](https://github.com/openai/CLIP/blob/3bee28119e6b28e75b82b811b87b56935314e6a5/clip/simple_tokenizer.py).
+A JavaScript port of
+[OpenAI's CLIP byte-pair-encoding tokenizer](https://github.com/openai/CLIP/blob/3bee28119e6b28e75b82b811b87b56935314e6a5/clip/simple_tokenizer.py).
 
 ```js
 import Tokenizer from "https://deno.land/x/clip_bpe@v0.0.6/mod.js";
@@ -18,10 +19,22 @@ t.encode("hello world!"); // [3306, 1002, 256]
 t.encodeForCLIP("hello world!"); // [49406,3306,1002,256,49407,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
 ```
 
-This encoder/decoder behaves differently to the the GPT-2/3 tokenizer (JavaScript version of that [here](https://github.com/latitudegames/GPT-3-Encoder)). For example, it doesn't preserve capital letters, as shown above.
-
-The [Python version](https://github.com/openai/CLIP/blob/3bee28119e6b28e75b82b811b87b56935314e6a5/clip/simple_tokenizer.py) of this tokenizer uses the `ftfy` module to clean up the text before encoding it. I didn't include that module by default because currently the only version available in JavaScript is [this one](https://github.com/josephrocca/ftfy-pyodide), which requires importing a full Python runtime as a WebAssembly module. If you want the `ftfy` cleaning, just import it and clean your text with it before passing it to the `.encode()` method.
+This encoder/decoder behaves differently to the the GPT-2/3 tokenizer
+(JavaScript version of that
+[here](https://github.com/latitudegames/GPT-3-Encoder)). For example, it doesn't
+preserve capital letters, as shown above.
+
+The
+[Python version](https://github.com/openai/CLIP/blob/3bee28119e6b28e75b82b811b87b56935314e6a5/clip/simple_tokenizer.py)
+of this tokenizer uses the `ftfy` module to clean up the text before encoding
+it. I didn't include that module by default because currently the only version
+available in JavaScript is
+[this one](https://github.com/josephrocca/ftfy-pyodide), which requires
+importing a full Python runtime as a WebAssembly module. If you want the `ftfy`
+cleaning, just import it and clean your text with it before passing it to the
+`.encode()` method.
 
 # License
 
-To the extent that there is any original work in this repo, it is MIT Licensed, just like [openai/CLIP](https://github.com/openai/CLIP).
+To the extent that there is any original work in this repo, it is MIT Licensed,
+just like [openai/CLIP](https://github.com/openai/CLIP).

+ 0 - 27
desktop/src/utils/common/index.ts

@@ -1,27 +0,0 @@
-import { app } from "electron";
-import { CustomErrors } from "../../constants/errors";
-export const isDev = !app.isPackaged;
-
-export 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(Error(CustomErrors.WAIT_TIME_EXCEEDED)),
-            timeout,
-        );
-    });
-    const requestWithTimeOutCancellation = async () => {
-        const resp = await request;
-        clearTimeout(timeoutRef.current);
-        return resp;
-    };
-    return await Promise.race([
-        requestWithTimeOutCancellation(),
-        rejectOnTimeout,
-    ]);
-};

+ 9 - 16
desktop/src/utils/createWindow.ts

@@ -3,12 +3,17 @@ import ElectronLog from "electron-log";
 import * as path from "path";
 import { isAppQuitting, rendererURL } from "../main";
 import autoLauncher from "../services/autoLauncher";
-import { logErrorSentry } from "../services/sentry";
+import { logErrorSentry } from "../main/log";
 import { getHideDockIconPreference } from "../services/userPreference";
-import { isDev } from "./common";
+import { isDev } from "../main/general";
 import { isPlatform } from "./common/platform";
 
-export async function createWindow(): Promise<BrowserWindow> {
+/**
+ * Create an return the {@link BrowserWindow} that will form our app's UI.
+ *
+ * This window will show the HTML served from {@link rendererURL}.
+ */
+export const createWindow = async () => {
     const appImgPath = isDev
         ? "resources/window-icon.png"
         : path.join(process.resourcesPath, "window-icon.png");
@@ -16,9 +21,7 @@ export async function createWindow(): Promise<BrowserWindow> {
     // Create the browser window.
     const mainWindow = new BrowserWindow({
         webPreferences: {
-            sandbox: false,
             preload: path.join(__dirname, "../preload.js"),
-            contextIsolation: false,
         },
         icon: appIcon,
         show: false, // don't show the main window on load,
@@ -49,16 +52,6 @@ export async function createWindow(): Promise<BrowserWindow> {
         );
         mainWindow.loadURL(rendererURL);
     }
-    mainWindow.webContents.on("did-fail-load", () => {
-        splash.close();
-        isDev
-            ? mainWindow.loadFile(`../resources/error.html`)
-            : splash.loadURL(
-                  `file://${path.join(process.resourcesPath, "error.html")}`,
-              );
-        mainWindow.maximize();
-        mainWindow.show();
-    });
     mainWindow.once("ready-to-show", async () => {
         try {
             splash.destroy();
@@ -114,4 +107,4 @@ export async function createWindow(): Promise<BrowserWindow> {
         }
     });
     return mainWindow;
-}
+};

+ 0 - 17
desktop/src/utils/error.ts

@@ -1,17 +0,0 @@
-import { CustomErrors } from "../constants/errors";
-
-export const isExecError = (err: any) => {
-    return err.message.includes("Command failed:");
-};
-
-export const parseExecError = (err: any) => {
-    const errMessage = err.message;
-    if (errMessage.includes("Bad CPU type in executable")) {
-        return CustomErrors.UNSUPPORTED_PLATFORM(
-            process.platform,
-            process.arch,
-        );
-    } else {
-        return errMessage;
-    }
-};

+ 15 - 50
desktop/src/utils/ipcComms.ts

@@ -4,14 +4,14 @@ import {
     BrowserWindow,
     dialog,
     ipcMain,
-    Notification,
     safeStorage,
     shell,
     Tray,
 } from "electron";
 import path from "path";
+import { clearElectronStore } from "../api/electronStore";
+import { attachIPCHandlers } from "../main/ipc";
 import {
-    getAppVersion,
     muteUpdateNotification,
     skipAppUpdate,
     updateAndRestart,
@@ -26,13 +26,6 @@ import {
     convertToJPEG,
     generateImageThumbnail,
 } from "../services/imageProcessor";
-import { logErrorSentry } from "../services/sentry";
-import {
-    getCustomCacheDirectory,
-    setCustomCacheDirectory,
-} from "../services/userPreference";
-import { getPlatform } from "./common/platform";
-import { createWindow } from "./createWindow";
 import { generateTempFilePath } from "./temp";
 
 export default function setupIpcComs(
@@ -40,6 +33,8 @@ export default function setupIpcComs(
     mainWindow: BrowserWindow,
     watcher: chokidar.FSWatcher,
 ): void {
+    attachIPCHandlers();
+
     ipcMain.handle("select-dir", async () => {
         const result = await dialog.showOpenDialog({
             properties: ["openDirectory"],
@@ -49,19 +44,6 @@ export default function setupIpcComs(
         }
     });
 
-    ipcMain.on("send-notification", (_, args) => {
-        const notification = {
-            title: "ente",
-            body: args,
-        };
-        new Notification(notification).show();
-    });
-    ipcMain.on("reload-window", async () => {
-        const secondWindow = await createWindow();
-        mainWindow.destroy();
-        mainWindow = secondWindow;
-    });
-
     ipcMain.handle("show-upload-files-dialog", async () => {
         const files = await dialog.showOpenDialog({
             properties: ["openFile", "multiSelections"],
@@ -98,10 +80,6 @@ export default function setupIpcComs(
         watcher.unwatch(args.dir);
     });
 
-    ipcMain.handle("log-error", (_, err, msg, info?) => {
-        logErrorSentry(err, msg, info);
-    });
-
     ipcMain.handle("safeStorage-encrypt", (_, message) => {
         return safeStorage.encryptString(message);
     });
@@ -110,7 +88,17 @@ export default function setupIpcComs(
         return safeStorage.decryptString(message);
     });
 
-    ipcMain.handle("get-path", (_, message) => {
+    ipcMain.on("clear-electron-store", () => {
+        clearElectronStore();
+    });
+
+    ipcMain.handle("convert-to-jpeg", (_, fileData, filename) => {
+        return convertToJPEG(fileData, filename);
+    });
+
+    ipcMain.handle("open-log-dir", () => {
+        // [Note: Electron app paths]
+        //
         // By default, these paths are at the following locations:
         //
         // * macOS: `~/Library/Application Support/ente`
@@ -119,14 +107,6 @@ export default function setupIpcComs(
         // * Windows: C:\Users\<you>\AppData\Local\<Your App Name>
         //
         // https://www.electronjs.org/docs/latest/api/app
-        return app.getPath(message);
-    });
-
-    ipcMain.handle("convert-to-jpeg", (_, fileData, filename) => {
-        return convertToJPEG(fileData, filename);
-    });
-
-    ipcMain.handle("open-log-dir", () => {
         shell.openPath(app.getPath("logs"));
     });
 
@@ -145,10 +125,6 @@ export default function setupIpcComs(
         muteUpdateNotification(version);
     });
 
-    ipcMain.handle("get-app-version", () => {
-        return getAppVersion();
-    });
-
     ipcMain.handle(
         "run-ffmpeg-cmd",
         (_, cmd, inputFilePath, outputFileName, dontTimeout) => {
@@ -180,15 +156,4 @@ export default function setupIpcComs(
     ipcMain.handle("compute-text-embedding", (_, model, text) => {
         return computeTextEmbedding(model, text);
     });
-    ipcMain.handle("get-platform", () => {
-        return getPlatform();
-    });
-
-    ipcMain.handle("set-custom-cache-directory", (_, directory: string) => {
-        setCustomCacheDirectory(directory);
-    });
-
-    ipcMain.handle("get-custom-cache-directory", async () => {
-        return getCustomCacheDirectory();
-    });
 }

+ 0 - 24
desktop/src/utils/logging.ts

@@ -1,24 +0,0 @@
-import log from "electron-log";
-
-export function setupLogging(isDev?: boolean) {
-    log.transports.file.fileName = "ente.log";
-    log.transports.file.maxSize = 50 * 1024 * 1024; // 50MB;
-    if (!isDev) {
-        log.transports.console.level = false;
-    }
-    log.transports.file.format =
-        "[{y}-{m}-{d}T{h}:{i}:{s}{z}] [{level}]{scope} {text}";
-}
-
-export function convertBytesToHumanReadable(
-    bytes: number,
-    precision = 2,
-): string {
-    if (bytes === 0 || isNaN(bytes)) {
-        return "0 MB";
-    }
-
-    const i = Math.floor(Math.log(bytes) / Math.log(1024));
-    const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
-    return (bytes / Math.pow(1024, i)).toFixed(precision) + " " + sizes[i];
-}

+ 2 - 6
desktop/src/utils/main.ts

@@ -1,14 +1,14 @@
 import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron";
 import ElectronLog from "electron-log";
+import { existsSync } from "node:fs";
 import os from "os";
 import path from "path";
-import { existsSync } from "promise-fs";
 import util from "util";
 import { rendererURL } from "../main";
 import { setupAutoUpdater } from "../services/appUpdater";
 import autoLauncher from "../services/autoLauncher";
 import { getHideDockIconPreference } from "../services/userPreference";
-import { isDev } from "./common";
+import { isDev } from "../main/general";
 import { isPlatform } from "./common/platform";
 import { buildContextMenu, buildMenuBar } from "./menu";
 const execAsync = util.promisify(require("child_process").exec);
@@ -94,10 +94,6 @@ export async function handleDockIconHideOnAutoLaunch() {
     }
 }
 
-export function enableSharedArrayBufferSupport() {
-    app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer");
-}
-
 export function logSystemInfo() {
     const systemVersion = process.getSystemVersion();
     const osName = process.platform;

+ 3 - 6
desktop/src/utils/menu.ts

@@ -7,6 +7,7 @@ import {
 } from "electron";
 import ElectronLog from "electron-log";
 import { setIsAppQuitting } from "../main";
+import { openDirectory, openLogDirectory } from "../main/general";
 import { forceCheckForUpdateAndNotify } from "../services/appUpdater";
 import autoLauncher from "../services/autoLauncher";
 import {
@@ -201,15 +202,11 @@ export async function buildMenuBar(mainWindow: BrowserWindow): Promise<Menu> {
                 { type: "separator" },
                 {
                     label: "View crash reports",
-                    click: () => {
-                        shell.openPath(app.getPath("crashDumps"));
-                    },
+                    click: () => openDirectory(app.getPath("crashDumps")),
                 },
                 {
                     label: "View logs",
-                    click: () => {
-                        shell.openPath(app.getPath("logs"));
-                    },
+                    click: openLogDirectory,
                 },
             ],
         },

+ 0 - 295
desktop/src/utils/processStats.ts

@@ -1,295 +0,0 @@
-import ElectronLog from "electron-log";
-import { webFrame } from "electron/renderer";
-import { convertBytesToHumanReadable } from "./logging";
-
-const LOGGING_INTERVAL_IN_MICROSECONDS = 30 * 1000; // 30 seconds
-
-const SPIKE_DETECTION_INTERVAL_IN_MICROSECONDS = 1 * 1000; // 1 seconds
-
-const MAIN_MEMORY_USAGE_DIFF_IN_KILOBYTES_CONSIDERED_AS_SPIKE = 50 * 1024; // 50 MB
-
-const HIGH_MAIN_MEMORY_USAGE_THRESHOLD_IN_KILOBYTES = 200 * 1024; // 200 MB
-
-const RENDERER_MEMORY_USAGE_DIFF_IN_KILOBYTES_CONSIDERED_AS_SPIKE = 200 * 1024; // 200 MB
-
-const HIGH_RENDERER_MEMORY_USAGE_THRESHOLD_IN_KILOBYTES = 1024 * 1024; // 1 GB
-
-async function logMainProcessStats() {
-    const processMemoryInfo = await getNormalizedProcessMemoryInfo(
-        await process.getProcessMemoryInfo(),
-    );
-    const cpuUsage = process.getCPUUsage();
-    const heapStatistics = getNormalizedHeapStatistics(
-        process.getHeapStatistics(),
-    );
-
-    ElectronLog.log("main process stats", {
-        processMemoryInfo,
-        heapStatistics,
-        cpuUsage,
-    });
-}
-
-let previousMainProcessMemoryInfo: Electron.ProcessMemoryInfo = {
-    private: 0,
-    shared: 0,
-    residentSet: 0,
-};
-
-let mainProcessUsingHighMemory = false;
-
-async function logSpikeMainMemoryUsage() {
-    const processMemoryInfo = await process.getProcessMemoryInfo();
-    const currentMemoryUsage = Math.max(
-        processMemoryInfo.residentSet ?? 0,
-        processMemoryInfo.private,
-    );
-    const previousMemoryUsage = Math.max(
-        previousMainProcessMemoryInfo.residentSet ?? 0,
-        previousMainProcessMemoryInfo.private,
-    );
-    const isSpiking =
-        currentMemoryUsage - previousMemoryUsage >=
-        MAIN_MEMORY_USAGE_DIFF_IN_KILOBYTES_CONSIDERED_AS_SPIKE;
-
-    const isHighMemoryUsage =
-        currentMemoryUsage >= HIGH_MAIN_MEMORY_USAGE_THRESHOLD_IN_KILOBYTES;
-
-    const shouldReport =
-        (isHighMemoryUsage && !mainProcessUsingHighMemory) ||
-        (!isHighMemoryUsage && mainProcessUsingHighMemory);
-
-    if (isSpiking || shouldReport) {
-        const normalizedCurrentProcessMemoryInfo =
-            await getNormalizedProcessMemoryInfo(processMemoryInfo);
-        const normalizedPreviousProcessMemoryInfo =
-            await getNormalizedProcessMemoryInfo(previousMainProcessMemoryInfo);
-        const cpuUsage = process.getCPUUsage();
-        const heapStatistics = getNormalizedHeapStatistics(
-            process.getHeapStatistics(),
-        );
-
-        ElectronLog.log("reporting main memory usage spike", {
-            currentProcessMemoryInfo: normalizedCurrentProcessMemoryInfo,
-            previousProcessMemoryInfo: normalizedPreviousProcessMemoryInfo,
-            heapStatistics,
-            cpuUsage,
-        });
-    }
-    previousMainProcessMemoryInfo = processMemoryInfo;
-    if (shouldReport) {
-        mainProcessUsingHighMemory = !mainProcessUsingHighMemory;
-    }
-}
-
-let previousRendererProcessMemoryInfo: Electron.ProcessMemoryInfo = {
-    private: 0,
-    shared: 0,
-    residentSet: 0,
-};
-
-let rendererUsingHighMemory = false;
-
-async function logSpikeRendererMemoryUsage() {
-    const processMemoryInfo = await process.getProcessMemoryInfo();
-    const currentMemoryUsage = Math.max(
-        processMemoryInfo.residentSet ?? 0,
-        processMemoryInfo.private,
-    );
-
-    const previousMemoryUsage = Math.max(
-        previousRendererProcessMemoryInfo.private,
-        previousRendererProcessMemoryInfo.residentSet ?? 0,
-    );
-    const isSpiking =
-        currentMemoryUsage - previousMemoryUsage >=
-        RENDERER_MEMORY_USAGE_DIFF_IN_KILOBYTES_CONSIDERED_AS_SPIKE;
-
-    const isHighMemoryUsage =
-        currentMemoryUsage >= HIGH_RENDERER_MEMORY_USAGE_THRESHOLD_IN_KILOBYTES;
-
-    const shouldReport =
-        (isHighMemoryUsage && !rendererUsingHighMemory) ||
-        (!isHighMemoryUsage && rendererUsingHighMemory);
-
-    if (isSpiking || shouldReport) {
-        const normalizedCurrentProcessMemoryInfo =
-            await getNormalizedProcessMemoryInfo(processMemoryInfo);
-        const normalizedPreviousProcessMemoryInfo =
-            await getNormalizedProcessMemoryInfo(
-                previousRendererProcessMemoryInfo,
-            );
-        const cpuUsage = process.getCPUUsage();
-        const heapStatistics = getNormalizedHeapStatistics(
-            process.getHeapStatistics(),
-        );
-
-        ElectronLog.log("reporting renderer memory usage spike", {
-            currentProcessMemoryInfo: normalizedCurrentProcessMemoryInfo,
-            previousProcessMemoryInfo: normalizedPreviousProcessMemoryInfo,
-            heapStatistics,
-            cpuUsage,
-        });
-    }
-    previousRendererProcessMemoryInfo = processMemoryInfo;
-    if (shouldReport) {
-        rendererUsingHighMemory = !rendererUsingHighMemory;
-    }
-}
-
-async function logRendererProcessStats() {
-    const blinkMemoryInfo = getNormalizedBlinkMemoryInfo();
-    const heapStatistics = getNormalizedHeapStatistics(
-        process.getHeapStatistics(),
-    );
-    const webFrameResourceUsage = getNormalizedWebFrameResourceUsage();
-    const processMemoryInfo = await getNormalizedProcessMemoryInfo(
-        await process.getProcessMemoryInfo(),
-    );
-    ElectronLog.log("renderer process stats", {
-        blinkMemoryInfo,
-        heapStatistics,
-        processMemoryInfo,
-        webFrameResourceUsage,
-    });
-}
-
-export function setupMainProcessStatsLogger() {
-    setInterval(
-        logSpikeMainMemoryUsage,
-        SPIKE_DETECTION_INTERVAL_IN_MICROSECONDS,
-    );
-    setInterval(logMainProcessStats, LOGGING_INTERVAL_IN_MICROSECONDS);
-}
-
-export function setupRendererProcessStatsLogger() {
-    setInterval(
-        logSpikeRendererMemoryUsage,
-        SPIKE_DETECTION_INTERVAL_IN_MICROSECONDS,
-    );
-    setInterval(logRendererProcessStats, LOGGING_INTERVAL_IN_MICROSECONDS);
-}
-
-export async function logRendererProcessMemoryUsage(message: string) {
-    const processMemoryInfo = await process.getProcessMemoryInfo();
-    const processMemory = Math.max(
-        processMemoryInfo.private,
-        processMemoryInfo.residentSet ?? 0,
-    );
-    ElectronLog.log(
-        "renderer ProcessMemory",
-        message,
-        convertBytesToHumanReadable(processMemory * 1024),
-    );
-}
-
-const getNormalizedProcessMemoryInfo = async (
-    processMemoryInfo: Electron.ProcessMemoryInfo,
-) => {
-    return {
-        residentSet: convertBytesToHumanReadable(
-            processMemoryInfo.residentSet * 1024,
-        ),
-        private: convertBytesToHumanReadable(processMemoryInfo.private * 1024),
-        shared: convertBytesToHumanReadable(processMemoryInfo.shared * 1024),
-    };
-};
-
-const getNormalizedBlinkMemoryInfo = () => {
-    const blinkMemoryInfo = process.getBlinkMemoryInfo();
-    return {
-        allocated: convertBytesToHumanReadable(
-            blinkMemoryInfo.allocated * 1024,
-        ),
-        total: convertBytesToHumanReadable(blinkMemoryInfo.total * 1024),
-    };
-};
-
-const getNormalizedHeapStatistics = (
-    heapStatistics: Electron.HeapStatistics,
-) => {
-    return {
-        totalHeapSize: convertBytesToHumanReadable(
-            heapStatistics.totalHeapSize * 1024,
-        ),
-        totalHeapSizeExecutable: convertBytesToHumanReadable(
-            heapStatistics.totalHeapSizeExecutable * 1024,
-        ),
-        totalPhysicalSize: convertBytesToHumanReadable(
-            heapStatistics.totalPhysicalSize * 1024,
-        ),
-        totalAvailableSize: convertBytesToHumanReadable(
-            heapStatistics.totalAvailableSize * 1024,
-        ),
-        usedHeapSize: convertBytesToHumanReadable(
-            heapStatistics.usedHeapSize * 1024,
-        ),
-
-        heapSizeLimit: convertBytesToHumanReadable(
-            heapStatistics.heapSizeLimit * 1024,
-        ),
-        mallocedMemory: convertBytesToHumanReadable(
-            heapStatistics.mallocedMemory * 1024,
-        ),
-        peakMallocedMemory: convertBytesToHumanReadable(
-            heapStatistics.peakMallocedMemory * 1024,
-        ),
-        doesZapGarbage: heapStatistics.doesZapGarbage,
-    };
-};
-
-const getNormalizedWebFrameResourceUsage = () => {
-    const webFrameResourceUsage = webFrame.getResourceUsage();
-    return {
-        images: {
-            count: webFrameResourceUsage.images.count,
-            size: convertBytesToHumanReadable(
-                webFrameResourceUsage.images.size,
-            ),
-            liveSize: convertBytesToHumanReadable(
-                webFrameResourceUsage.images.liveSize,
-            ),
-        },
-        scripts: {
-            count: webFrameResourceUsage.scripts.count,
-            size: convertBytesToHumanReadable(
-                webFrameResourceUsage.scripts.size,
-            ),
-            liveSize: convertBytesToHumanReadable(
-                webFrameResourceUsage.scripts.liveSize,
-            ),
-        },
-        cssStyleSheets: {
-            count: webFrameResourceUsage.cssStyleSheets.count,
-            size: convertBytesToHumanReadable(
-                webFrameResourceUsage.cssStyleSheets.size,
-            ),
-            liveSize: convertBytesToHumanReadable(
-                webFrameResourceUsage.cssStyleSheets.liveSize,
-            ),
-        },
-        xslStyleSheets: {
-            count: webFrameResourceUsage.xslStyleSheets.count,
-            size: convertBytesToHumanReadable(
-                webFrameResourceUsage.xslStyleSheets.size,
-            ),
-            liveSize: convertBytesToHumanReadable(
-                webFrameResourceUsage.xslStyleSheets.liveSize,
-            ),
-        },
-        fonts: {
-            count: webFrameResourceUsage.fonts.count,
-            size: convertBytesToHumanReadable(webFrameResourceUsage.fonts.size),
-            liveSize: convertBytesToHumanReadable(
-                webFrameResourceUsage.fonts.liveSize,
-            ),
-        },
-        other: {
-            count: webFrameResourceUsage.other.count,
-            size: convertBytesToHumanReadable(webFrameResourceUsage.other.size),
-            liveSize: convertBytesToHumanReadable(
-                webFrameResourceUsage.other.liveSize,
-            ),
-        },
-    };
-};

+ 5 - 8
desktop/src/utils/temp.ts

@@ -1,17 +1,14 @@
-import { app } from "electron";
+import { app } from "electron/main";
+import { existsSync } from "node:fs";
+import * as fs from "node:fs/promises";
 import path from "path";
-import { existsSync, mkdir } from "promise-fs";
-
-const ENTE_TEMP_DIRECTORY = "ente";
 
 const CHARACTERS =
     "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
 
 export async function getTempDirPath() {
-    const tempDirPath = path.join(app.getPath("temp"), ENTE_TEMP_DIRECTORY);
-    if (!existsSync(tempDirPath)) {
-        await mkdir(tempDirPath);
-    }
+    const tempDirPath = path.join(app.getPath("temp"), "ente");
+    await fs.mkdir(tempDirPath, { recursive: true });
     return tempDirPath;
 }
 

+ 71 - 6
desktop/tsconfig.json

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

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 229 - 423
desktop/yarn.lock


+ 76 - 7
docs/docs/.vitepress/sidebar.ts

@@ -12,15 +12,32 @@ export const sidebar = [
                 items: [
                     { text: "Albums", link: "/photos/features/albums" },
                     { text: "Archiving", link: "/photos/features/archive" },
+                    {
+                        text: "Background sync",
+                        link: "/photos/features/background",
+                    },
+                    { text: "Backup", link: "/photos/features/backup" },
                     { text: "Cast", link: "/photos/features/cast/" },
+                    {
+                        text: "Collaboration",
+                        link: "/photos/features/collaborate",
+                    },
                     {
                         text: "Collecting photos",
                         link: "/photos/features/collect",
                     },
+                    {
+                        text: "Deduplicate",
+                        link: "/photos/features/deduplicate",
+                    },
                     {
                         text: "Family plans",
                         link: "/photos/features/family-plans",
                     },
+                    {
+                        text: "Free up space",
+                        link: "/photos/features/free-up-space/",
+                    },
                     { text: "Hidden photos", link: "/photos/features/hide" },
                     {
                         text: "Location tags",
@@ -32,20 +49,72 @@ export const sidebar = [
                         link: "/photos/features/public-link",
                     },
                     { text: "Quick link", link: "/photos/features/quick-link" },
-                    { text: "Referrals", link: "/photos/features/referrals" },
-                    { text: "Sharing", link: "/photos/features/sharing" },
+                    {
+                        text: "Referral program",
+                        link: "/photos/features/referral-program/",
+                    },
+                    { text: "Sharing", link: "/photos/features/share" },
                     { text: "Trash", link: "/photos/features/trash" },
                     {
                         text: "Uncategorized",
                         link: "/photos/features/uncategorized",
                     },
                     {
-                        text: "Watch folder",
-                        link: "/photos/features/watch-folder",
+                        text: "Watch folders",
+                        link: "/photos/features/watch-folders",
+                    },
+                ],
+            },
+            {
+                text: "Migration",
+                collapsed: true,
+                items: [
+                    {
+                        text: "Introduction",
+                        link: "/photos/migration/",
+                    },
+
+                    {
+                        text: "From Google Photos",
+                        link: "/photos/migration/from-google-photos/",
+                    },
+                    {
+                        text: "From Apple Photos",
+                        link: "/photos/migration/from-apple-photos/",
+                    },
+                    {
+                        text: "From Amazon Photos",
+                        link: "/photos/migration/from-amazon-photos",
+                    },
+                    {
+                        text: "From your hard disk",
+                        link: "/photos/migration/from-local-hard-disk",
+                    },
+                    {
+                        text: "Exporting your data",
+                        link: "/photos/migration/export/",
+                    },
+                ],
+            },
+            {
+                text: "FAQ",
+                collapsed: true,
+                items: [
+                    { text: "General", link: "/photos/faq/general" },
+                    {
+                        text: "Security and privacy",
+                        link: "/photos/faq/security-and-privacy",
+                    },
+                    {
+                        text: "Subscription and plans",
+                        link: "/photos/faq/subscription",
+                    },
+                    {
+                        text: "Hide vs archive",
+                        link: "/photos/faq/hidden-and-archive",
                     },
                 ],
             },
-            { text: "FAQ", link: "/photos/faq/" },
             {
                 text: "Troubleshooting",
                 collapsed: true,
@@ -68,7 +137,7 @@ export const sidebar = [
             { text: "Introduction", link: "/auth/" },
             { text: "FAQ", link: "/auth/faq/" },
             {
-                text: "Migration guides",
+                text: "Migration",
                 collapsed: false,
                 items: [
                     { text: "Introduction", link: "/auth/migration-guides/" },
@@ -185,7 +254,7 @@ function sidebarOld() {
                         },
                         {
                             text: "Watch folder",
-                            link: "/photos/features/watch-folder",
+                            link: "/photos/features/watch-folders",
                         },
                         { text: "Trash", link: "/photos/features/trash" },
                         {

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

@@ -94,11 +94,11 @@ to ente Authenticator!
 ### Method 2.1: If the export worked, but the import didn't
 
 > [!NOTE]
-> 
-> This is intended only for users who successfully exported their codes using the
-> guide in method 2, but could not import it to ente Authenticator for whatever
-> reason. If the import was successful, or you haven't tried to import the codes
-> yet, ignore this section.
+>
+> This is intended only for users who successfully exported their codes using
+> the guide in method 2, but could not import it to ente Authenticator for
+> whatever reason. If the import was successful, or you haven't tried to import
+> the codes yet, ignore this section.
 >
 > If the export itself failed, try using
 > [**method 1**](#method-1-use-neerajs-export-tool) instead.

+ 76 - 0
docs/docs/photos/faq/general.md

@@ -0,0 +1,76 @@
+---
+title: General FAQ
+description: An assortment of frequently asked questions about Ente Photos
+---
+
+# General FAQ
+
+## How can I earn free storage?
+
+Use our [referral program](/photos/features/referral-program/).
+
+## What file formats does Ente support?
+
+Ente supports all files that have a mime type of `image/*` or `video/*`
+regardless of their specific format.
+
+However, we only have limited support for RAW currently. We are working towards
+adding full support, and you can watch this
+[thread](https://github.com/ente-io/ente/discussions/625) for updates.
+
+If you find an issue with ente's ability to parse a certain file type, please
+write to [support@ente.io](mailto:support@ente.io) with details of the
+unsupported file format and we will do our best to help you out.
+
+## Is there a file size limit?
+
+Yes, we currently do not support files larger than 4 GB.
+
+If this constraint is a concern for you, please write to
+[support@ente.io](mailto:support@ente.io) with your use case and we will do our
+best to help you.
+
+## Does Ente support videos?
+
+Ente supports backing up and downloading of videos in their original format and
+quality.
+
+But some of these formats cannot be streamed on the web browser and you will be
+prompted to download them.
+
+## Why does Ente consume lesser storage than other providers?
+
+Most storage providers compute your storage quota in GigaBytes (GBs) by dividing
+your total bytes uploaded by `1000 x 1000 x 1000`.
+
+Ente on the other hand, computes your storage quota in GibiBytes (GiBs) by
+dividing your total bytes uploaded by `1024 x 1024 x 1024`.
+
+We decided to leave out the **i** from **GiBs** to reduce noise on our
+interfaces.
+
+## Why should I trust Ente for long-term data-storage?
+
+Unlike large companies, we have a focused mission, to build a safe space where
+you can easily archive your personal memories.
+
+This is the only thing we want to do, and with our pricing model, we can
+profitably do it.
+
+We preserve your data end-to-end encrypted, and our open source apps have been
+[externally audited](https://ente.io/blog/cryptography-audit/).
+
+Also, we have spent great deal of engineering effort into designing reliable
+data replication and graceful disaster recovery plans. This is also done
+transparently - we have documented the specifics of our replication and
+reliability [here](https://ente.io/reliability).
+
+In short, we love what we do, we have no reasons to be distracted, and we are as
+reliable as any one can be.
+
+If you would like to fund the development of this project, please consider
+[subscribing](https://ente.io/download).
+
+## How do I pronounce ente?
+
+It's like cafe 😊. kaf-_ay_. en-_tay_.

+ 36 - 0
docs/docs/photos/faq/hidden-and-archive.md

@@ -0,0 +1,36 @@
+---
+title: Can I hide photos in ente?
+description: Two related ways of hiding or archiving in Ente Photos
+---
+
+# Can I hide photos in ente?
+
+Yes, you can hide specific photos and videos in Ente using the "Hide" action.
+Open the photo, expand the overflow menu and select Hide (the action with the
+eye icon).
+
+Hidden items do not appear anywhere in Ente except within the special "Hidden"
+category. Ente will ask for the device biometric (FaceID / TouchID) or passcode
+to view the contents of the Hidden category.
+
+You can reach the Hidden category from the bottom of the albums screen.
+
+Keep in mind that hidden items will still show up in the "On device" albums
+within Ente as long as they are present in your native gallery. But once you
+remove them from your device, they'll stop showing up here.
+
+Hiding is currently only supported in the Ente mobile app, and items hidden from
+the mobile app will not be visible in the web and desktop app.
+
+For more details, see [features/hide](/photos/features/hide).
+
+### Archive
+
+There is also a related feature called "Archive". While hidden items do not
+appear anywhere, archived items do not appear in your timeline but can otherwise
+be seen within the album and search results.
+
+This is useful when you're not trying to hide certain photos per se, but just do
+not want some of them (say, some old screenshots) to clutter your home timeline.
+
+For more details, see [features/archive](/photos/features/archive).

+ 0 - 9
docs/docs/photos/faq/index.md

@@ -1,9 +0,0 @@
----
-title: FAQ
-description: Frequently asked questions about Ente Photos
----
-
-# FAQ
-
-_Coming soon_. On this page we'll document some help items in a question and
-answer format.

+ 82 - 0
docs/docs/photos/faq/security-and-privacy.md

@@ -0,0 +1,82 @@
+---
+title: Security and privacy FAQ
+description:
+    Frequently asked questions about security and privacy of Ente Photos
+---
+
+# Security and privacy
+
+## Can Ente see my photos and videos?
+
+No.
+
+Your files are encrypted with a key before they are uploaded to our servers.
+
+These keys can be accessed only with your password.
+
+Since only you know your password, only you can decrypt your files.
+
+To learn more about our encryption protocol, please read about our
+[architecture](https://ente.io/architecture).
+
+## How is my data encrypted?
+
+We use [libsodium](https://libsodium.gitbook.io/doc/)'s implementations
+`XChaCha20` and `XSalsa20` to encrypt your data, along with `Poly1305` MAC for
+authentication.
+
+Please refer to the document on our [architecture](https://ente.io/architecture)
+for more details.
+
+## Where is my data stored?
+
+Your data is replicated to multiple providers in different countries in the EU.
+
+Currently we have datacenters in the following locations:
+
+-   Amsterdam, Netherlands
+-   Paris, France
+-   Frankfurt, Germany
+
+Much more details about our replication and reliability are documented
+[here](https://ente.io/reliability).
+
+## What happens if I forget my password?
+
+You can reset your password with your recovery key.
+
+If you lose both your password and your recovery key, you will not be able to
+decrypt your data.
+
+## Can I change my password?
+
+Yes.
+
+You can change your password from any of our apps.
+
+Thanks to our [architecture](https://ente.io/architecture), you can do so
+without having to re-encrypt any of your files.
+
+The privacy of your account is a function of the strength of your password,
+please choose a strong one.
+
+## Do you support 2FA?
+
+Yes.
+
+You can setup two-factor authentication from the settings screen of the mobile
+app or from the side bar of our desktop app.
+
+## How does sharing work?
+
+The information required to decrypt an album is encrypted with the recipient's
+public key such that only they can decrypt them.
+
+You can read more about this [here](https://ente.io/architecture#sharing).
+
+In case of sharable links, the key to decrypt the album is appended by the
+client as a [fragment to the URL](https://en.wikipedia.org/wiki/URI_fragment),
+and is never sent to our servers.
+
+Please note that only users on the paid plan are allowed to share albums. The
+receiver just needs a free Ente account.

+ 158 - 0
docs/docs/photos/faq/subscription.md

@@ -0,0 +1,158 @@
+---
+title: Subscription FAQ
+description: Frequently asked questions about Ente Photos subscription and plans
+---
+
+# Subscription and plans
+
+See our [website](https://ente.io#pricing) for the list of supported plans and
+pricing.
+
+## Does Ente have Family Plans?
+
+Yes we do! Please check out our announcement post
+[here](https://ente.io/blog/family-plans).
+
+In brief,
+
+-   Your family members can use storage space from your plan without paying
+    extra.
+
+-   Ask them to sign up for Ente, and then just add them to your existing plan
+    using the "Manage family" option within your Subscription settings.
+
+-   Each member gets their own private space, and cannot see each other's files
+    unless they're shared.
+
+-   You can invite 5 family members. So including yourself, it will be 6 people
+    who can share a single subscription, paying only once.
+
+Note that family plans are meant as a way to share storage. For sharing photos,
+you can create [shared albums and links](/photos/features/share).
+
+## Does Ente offer discounts to students?
+
+Yes we do!
+
+We believe that privacy should be made accessible to everyone. In this spirit,
+we offer **30% off** our subscription plans to students.
+
+To apply for this discount, please verify your enrollment status in a school /
+college / university by writing to [students@ente.io](mailto:students@ente.io)
+from the email address assigned to you by your institute.
+
+In case you do not have access to such an email address, please send us proof
+(such as your institute's identity card) that verifies your identity as a
+student.
+
+Please note that these discounts are valid for a year, after which you may
+reapply to reclaim the discount.
+
+## What payment methods does Ente support?
+
+On Web, Desktop and Android, Stripe helps us process payments from all major
+prepaid and credit card providers.
+
+On iOS, we (have to) use the billing platforms provided by the app store.
+
+Apart from these, we also support PayPal and crypto currencies (more details
+below).
+
+## Can I pay with PayPal?
+
+We support **annual** subscriptions over PayPal.
+
+Please drop an email to paypal@ente.io from your registered email address,
+mentioning the [storage plan](https://ente.io#pricing) of your choice and we
+will send you an invoice with a link to complete the payment.
+
+Once the payment is completed, your account will be upgraded to the chosen plan.
+
+## Does Ente accept crypto payments?
+
+We accept the following crypto currencies:
+
+-   Bitcoin
+-   Ethereum
+-   Dogecoin
+
+To purchase a subscription with any of the above mentioned currencies, please
+write to crypto@ente.io from your registered email address, citing the
+[storage plan](https://ente.io#pricing) of your choice.
+
+In case you have any further questions or need support, please reach out to
+[support@ente.io](mailto:support@ente.io), and we'll be happy to help!
+
+> Please note that Ente does not provide anonymity. What we provide is privacy,
+> since your data is end-to-end encrypted.
+> [Information](https://ente.io/privacy/#3-what-information-do-we-collect) we
+> have about you might make your identity deducible. We are accepting crypto as
+> a way to make Ente more accessible, not to provide anonymity.
+
+## Does Ente store my card details?
+
+Ente does not store any of your sensitive payment related information.
+
+We use [Stripe](https://stripe.com) to handle our card payments, and all of your
+payment information is sent directly to Stripe's PCI DSS validated servers.
+
+Stripe has been audited by a PCI-certified auditor and is certified to
+[PCI Service Provider Level 1](https://www.visa.com/splisting/searchGrsp.do?companyNameCriteria=stripe).
+This is the most stringent level of certification available in the payments
+industry.
+
+All of this said, if you would still like to pay without sharing your card
+details, you can pay using PayPal.
+
+## What happens if I exceed my storage limit?
+
+Ente will stop backing up your files and you will receive an email alerting you
+of the same.
+
+Your backed up files will remain accessible for as long as you have an active
+subscription.
+
+## What happens when my subscription expires?
+
+30 days after your subscription expires, all of your uploaded data will be
+cleared from our servers.
+
+You will receive an email prompting you to take out all of your backed up data
+before this happens.
+
+## What happens when I upgrade my plan?
+
+Your new plan will go into effect immediately, and you only have to pay the
+difference. We will adjust your remaining pro-rated balance on the old plan when
+invoicing you for the new plan.
+
+For example, if you are half way through the year on the 100 GB yearly plan, and
+upgrade to the 500 GB yearly plan, then
+
+-   The new 500 GB yearly plan will go into effect immediately.
+
+-   But we will reduce the charges for the first year by subtracting the
+    remaining half year balance of the 100 GB yearly plan that you'd already
+    paid.
+
+The same applies to monthly plans.
+
+## Is there an x GB plan?
+
+We have experimented quite a bit and have found it hard to design a single
+structure that fits all needs. Some customers wish for many options, some even
+wish to go to an extreme of dynamic per GB pricing. Other customers wish to keep
+everything simple, some even wish for a single unlimited plan.
+
+To keep things fair, our plans don't increase linearly, and the tiers are such
+that cover the most requested patterns.
+
+In addition, we also offer [family plans](/photos/features/family-plans) so that
+you can gain more value out of a single subscription.
+
+## Is there a forever-free plan?
+
+Sorry, since we're building a business that does not involve monetization of
+user data, we have to charge to remain sustainable.
+
+We do offer a generous free trial for you to experience the product.

+ 57 - 0
docs/docs/photos/features/background.md

@@ -0,0 +1,57 @@
+---
+title: Background sync
+description: Ente Photos supports automatic background sync and backup
+---
+
+# Background sync
+
+Ente Photos supports seamless background sync so that you don't need to open the
+app to backup your photos. It will sync in the background and automatically
+backup the albums that you have selected for syncing.
+
+Day to day sync will work automatically. However, there are some platform
+specific considerations that apply, more on these below:
+
+### iOS
+
+On iOS, if you have a very large number of photos and videos, then you might
+need to keep Ente running in the foreground for the first backup to happen
+(since we get only a limited amount of background execution time). To help with
+this, under "Settings > Backup" there is an option to disable the automatic
+device screen lock. But once your initial backup has completed, subsequent
+backups will work fine in the background and don't need disabling the screen
+lock.
+
+On iOS, Ente will not backup videos in the background (since videos are usually
+much larger and need more time to upload than what we get). However, they will
+get backed up the next time the Ente app is opened.
+
+Note that the Ente app will not be able to backup in the background if you force
+kill the app.
+
+> If you're curious, the way this works is, our servers "tickle" your device
+> every once in a while by sending a silent push notification, which wakes up
+> our app and gives it 30 seconds to execute a background sync. However, if you
+> have killed the app from recents, iOS will not deliver the push to the app,
+> breaking the background sync.
+
+### Android
+
+On some Android versions, newly downloaded apps activate a mode called "Optimize
+battery usage" which prevents them from running in the background. So you will
+need to disable this "Optimize battery usage" mode in the system settings for
+Ente if you wish for Ente to automatically back up your photos in the
+background.
+
+### Desktop
+
+In addition to our mobile apps, the background sync also works on our desktop
+app, though the [way that works](watch-folders) is a bit different.
+
+---
+
+## Troubleshooting
+
+-   On iOS, make sure that you're not killing the Ente app.
+-   On Android, make sure that "Optimize battery usage" is not turned on in
+    system settings for the Ente app.

+ 22 - 0
docs/docs/photos/features/backup.md

@@ -0,0 +1,22 @@
+---
+title: Backup
+description: Details about how backup works in Ente Photos
+---
+
+# Backup
+
+Ente will automatically backup any albums in your native photos app that you
+select for backup.
+
+Ente will run in the background, and any new photos added to these albums (or
+any photos in these albums that were modified) will be automatically synced to
+ente.
+
+You can choose which albums should be backed up when you sign up for Ente. If
+you change your mind later, or if you create a new album in your native photos
+app that you also want to backup, please use "Settings > Backup > Backed up
+folders" to modify your choices.
+
+If a file is deleted on your native photos app, it will still show up in Ente.
+This is because on both iOS and Android, apps are not allowed to automatically
+delete user's photos without a manual confirmation.

+ 63 - 0
docs/docs/photos/features/collaborate.md

@@ -0,0 +1,63 @@
+---
+title: Collaboration
+description: Collaborate with other people using shared albums and public links
+---
+
+# Collaborate
+
+Ente allows you to collaborate with people in 2 ways:
+
+-   Collaborative albums
+
+-   Collaborative links
+
+## Collaborative albums
+
+Collaborative albums allow multiple Ente users to add photos to the same shared
+album. Storage is only counted once, irrespective of the number of collaborators
+and viewers.
+
+-   The owner of the album is the person who created it.
+
+-   The owner can add collaborators and viewers by their email. The owner can
+    also change permissions of participants at any time, and remove them.
+
+-   Collaborators can add photos (and videos) to the shared album.
+
+-   The storage of the photo is counted towards the owner of the photo - the
+    person who uploaded it. Since the uploader usually has the photo in their
+    account anyway, effectively this means that the photo can be added to a
+    collaborative album without paying anything extra.
+
+-   The owner of the photo can remove it from the album (or delete it).
+
+-   The owner of the album can remove all photos from the album (they can only
+    delete the photos they own).
+
+-   When a collaborator is removed from a shared album (or when they leave the
+    album), any photos they'd uploaded will also be removed.
+
+Currently collaborative albums can only be used from the mobile app. A
+collaborator will see them in view only mode in the web and desktop apps; we're
+actively working on adding support for them on web and desktop too.
+
+## Collaborative links
+
+Collaborative links allow you to collaborate with people who might not have an
+Ente account or the Ente apps.
+
+-   You can create a public link, and anyone with access to the link will be
+    able to view the shared photos using just their web browser (no login
+    required).
+
+-   You can enable the "Allow adding photos" option on a public link to allow
+    people to also add photos the same way (from their web browser, no login
+    required).
+
+Such collaborative links are also sometimes called "collect links", since they
+allow you to collect photos from people without them needing Ente accounts. A
+common use case for this is collecting event and trip photos from a big circle
+of people.
+
+The storage for the photos added to a collaborative link are counted towards the
+album owner. The owner can also remove these photos at any time.

+ 59 - 0
docs/docs/photos/features/deduplicate.md

@@ -0,0 +1,59 @@
+---
+title: Deduplicate
+description: Removing duplicates photos using Ente Photos
+---
+
+# Deduplicate
+
+Ente performs two different duplicate detections: one during uploads, and one
+that can be manually run afterwards to remove duplicates across albums.
+
+## During uploads
+
+Ente will automatically deduplicate and ignore duplicate files during uploads.
+
+When uploading, Ente will ignore exact duplicate files. This allows you to
+resume interrupted uploads, or drag and drop the same folder, or reinstall the
+app, and expect Ente to automatically skip duplicates and only add new files.
+
+The duplicate detection works slightly different on each platform, to cater to
+the platform's nuances.
+
+#### Mobile
+
+-   On iOS, a hash will be used to detect exact duplicates. If the duplicate is
+    being uploaded to an album where a photo with the same hash already exists,
+    then the duplicate will be skipped. If it is being uploaded to a different
+    album, then a symlink will be created (so no actual data will need to be
+    uploaded, just a symlink will be created to the existing file).
+
+-   On Android also, a hash check is used. But unlike iOS, the native Android
+    filesystem behaviour is to keep physical copies if the same photo is in
+    different albums. So Ente does the same: duplicates to same album will be
+    skipped, duplicates when going to separate albums will create copies.
+
+#### Web and desktop
+
+On laptops (i.e. when using the Ente web or desktop app), in addition to a hash
+check, the file name is also used. The assumption is that the user wishes to
+keep two copies if they have the same file but with different names.
+
+Thus a file will be considered a duplicate and skipped during upload if a file
+with the same name and hash already exists in the album.
+
+And if you're trying to upload it to a different album (i.e. the same file with
+the same name already exists in a different album), then a symlink to the
+existing file will be created. This is similar to what happens when you do "Add
+to album", and the actual files are not re-uploaded.
+
+## Manual deduplication
+
+Ente also provides a tool for manual de-duplication in _Settings → Backup →
+Remove duplicates_. This is useful if you have an existing library with
+duplicates across different albums, but wish to keep only one copy.
+
+## Adding to Ente album creates symlinks
+
+Note that once a file in is Ente, adding it to another Ente album will create a
+symlink, so that you can add it to as many albums as you wish but storage will
+only be counted once.

+ 7 - 4
docs/docs/photos/features/family-plans.md

@@ -23,9 +23,12 @@ In brief,
 -   You can invite 5 family members. So including yourself, it will be 6 people
     who can share a single subscription, paying only once.
 
-
 ## FAQ
 
-* **Can you assign a storage quota for each individual member in the family plan?**   
-   Unfortunately, at this moment, assigning a storage quota for each individual member in the family plan is not supported. For updates on this feature request, please follow  [this thread](https://github.com/ente-io/ente/discussions/857).
- 
+-   **Can you assign a storage quota for each individual member in the family
+    plan?**
+
+    Unfortunately, at this moment, assigning a storage quota for each individual
+    member in the family plan is not supported. For updates on this feature
+    request, please follow
+    [this thread](https://github.com/ente-io/ente/discussions/857).

BIN
docs/docs/photos/features/free-up-space/free-up-space.png


+ 18 - 0
docs/docs/photos/features/free-up-space/index.md

@@ -0,0 +1,18 @@
+---
+title: Free up space
+description: Freeing up your phone's storage space when using Ente Photos
+---
+
+# Free up your phone's storage space
+
+Within the app's settings page, you have an option to free up space by deleting
+all backed up photos and videos from your phone's internal storage.
+
+<div align="center">
+
+![Free up space screen](free-up-space.png){width=400px}
+
+</div>
+
+> Note: You might have to clear the device's trash to realize this cleared
+> space.

+ 2 - 2
docs/docs/photos/features/public-links.md → docs/docs/photos/features/public-link.md

@@ -1,11 +1,11 @@
 ---
-title: Public links
+title: Public link
 description:
     Share photos with your friends and family without them needing to install
     Ente Photos
 ---
 
-# Public Links
+# Public link
 
 Ente lets you share your photos via links, that can be accessed by anyone,
 without an app or account.

+ 3 - 3
docs/docs/photos/features/quick-link.md

@@ -1,9 +1,9 @@
 ---
-title: Quick links
+title: Quick link
 description: Share photos with your friends and family without creating albums
 ---
 
-# Quick Links
+# Quick link
 
 Quick links allows you to select single or multiple photos and create a link
 that you can then share. You don't need to create an album first.
@@ -18,5 +18,5 @@ that you can then share. You don't need to create an album first.
 
 -   Removing a link will not delete the photos that are present in that link.
 
--   Similar to [public-links](./public-links), you can set link expiry,
+-   Similar to a [public-link](./public-link), you can set link expiry,
     passwords or device limits.

BIN
docs/docs/photos/features/referral-program/free-storage.png


+ 65 - 0
docs/docs/photos/features/referral-program/index.md

@@ -0,0 +1,65 @@
+---
+title: Referral program
+description: Earn free storage by referring Ente Photos to your friends
+---
+
+# Referral program
+
+You can refer your friends to earn free storage on Ente.
+
+For each friend you refer, who upgrades to a paid plan, we will credit **10 GB**
+of free storage. The referred customer will also receive an additional **10 GB**
+with their paid subscription.
+
+That is, if you refer a friend, once your friend upgrades to a paid plan, both
+you and your friend receive an additional 10 GB of storage.
+
+You can find your referral code under _Settings → General → Referrals_.
+
+<div align="center">
+
+![Claim free storage screen](free-storage.png){width=400px}
+
+</div>
+
+### How much storage can I earn?
+
+The amount of free storage you can earn is capped to your current plan. This
+means, you can at max <u>double your storage</u>. For example, if you're on a
+100 GB plan, you can earn another 100 GB (by referring 10 friends), taking your
+total available storage to 200 GB.
+
+You can keep track of your earned storage and referral details on _Claim free
+storage_ screen.
+
+If you refer more paid customers than is allowed by your current plan, the extra
+storage earned will be reserved and will become usable once you upgrade your
+plan.
+
+### For how long do I have access to this storage?
+
+Earned storage will be accessible as long as your subscription is active,
+provided there has been no abuse.
+
+In case our systems detect abuse, we may notify you and take back credited
+storage. Low quality referrals (who don't renew their plans) or creation of fake
+accounts, etc. could result in this.
+
+### How can my friends apply my referral code?
+
+Referral codes can be applied within _Settings → General → Referrals → Apply
+Code_.
+
+<div align="center">
+
+![Apply referral code screen](referral-code-application.png){width=400px}
+
+</div>
+
+Please note that referral codes should be applied within one month of account
+creation to claim free storage.
+
+---
+
+More questions? Drop a mail to [referrals@ente.io](mailto:referrals@ente.io),
+and we'll get back to you!

BIN
docs/docs/photos/features/referral-program/referral-code-application.png


+ 0 - 48
docs/docs/photos/features/referrals.md

@@ -1,48 +0,0 @@
----
-title: Referral plan
-description:
-    Earn and expand your storage by referring Ente Photos to your friends and
-    family
----
-
-# Referral plan
-
-_Earn and Expand Your Storage_
-
-Did you know you can boost your storage on Ente simply by referring your
-friends? Our referral program lets you earn 10 GB of free storage for each
-friend who upgrades to a paid plan, and your referred friends receive an
-additional 10 GB with their subscription.
-
-## How to Refer a friend?
-
-On the Home Page:
-
--   Click on the hamburger menu in the top left corner
--   Open the sidebar
--   Tap on _General_
--   Select _Referrals_
--   Share the code with your friend or family
-
-Note:
-
--   Once your friend upgrades to a paid plan, both you and your friend receive
-    an additional 10 GB of storage.
--   You can keep track of your earned storage and referral details on _Claim
-    free storage_ screen.
--   If you refer more friends than your plan allows, the extra storage earned
-    will be reserved until you upgrade your plan.
--   Earned storage remains accessible as long as your subscription is active.
-
-## How to apply referral code given by a friend?
-
-On the Home Page:
-
--   Click on the hamburger menu inthe top left corner
--   Tap on _General_ from the options
--   Select _Referrals_ from the menu
--   Find and tap on _Apply Code_
--   Enter the referral code provided by your friend.
-
-Please note that referral codes should be applied within one month of account
-creation to claim the free storage.

+ 75 - 0
docs/docs/photos/features/share.md

@@ -0,0 +1,75 @@
+---
+title: Share
+description: Securely share photos and videos stored in Ente Photos
+---
+
+# Sharing
+
+Ente supports end-to-end encrypted sharing of your photos and videos.
+
+This allows you to share your photos and videos with only the people you want,
+without them being visible to anybody else. The files remain encrypted at all
+times, and only the people you have shared with get the decryption keys.
+
+-   If the person you want to share with is already on Ente, you can share an
+    album with them by entering their email address.
+
+-   If they are not already on Ente, you can send them an invite and then share
+    with them after they've signed up.
+
+-   Alternatively, you can create public links to share albums with people who
+    are not on Ente.
+
+With public links, the files are still end-to-end encrypted, so the sharing is
+still secure. Note that the decryption keys are part of the public link so keep
+in mind that anybody with the link will be able to share it with others.
+
+Both shared albums and public links allow [collaboration](collaborate).
+
+## Links
+
+You can create links to your albums by opening an album and clicking on the
+Share icon. They are publicly accessible by anyone who you share the link with.
+They don't need an app or account.
+
+These links can be password protected, or set to expire after a while.
+
+You can read more about the features supported by Links
+[here](https://ente.io/blog/powerful-links/).
+
+## Albums
+
+If your loved ones are already on Ente, you can share an album with their
+registered email address.
+
+If they are your partner, you can share your `Camera` folder on Android, or
+`Recents` on iOS. Whenever you click new photos, they will automatically be
+accessible on your partner's device.
+
+## Collaboration
+
+You can allow other Ente users to add photos to your album. This is a great way
+for you to build an album together with someone. You can control access to the
+same album - someone can be added as a `Collaborator`, while someone else as a
+`Viewer`.
+
+If you wish to collect photos from folks who are not Ente, you can do so with
+our Links. Simply tick the box that says "Allow uploads", and anyone who has
+access to the link will be able to add photos to your album.
+
+## Technical details
+
+More details, including technical aspect about how the sharing features were
+implemented, are in various blog posts announcing these features.
+
+-   [Collaborative albums](https://ente.io/blog/collaborative-albums)
+
+-   [Collect photos from people not on ente](https://ente.io/blog/collect-photos)
+
+-   [Shareable links for albums](https://ente.io/blog/shareable-links),
+    [and their underlying technical implementation](https://ente.io/blog/building-shareable-links).
+    Since then, we have also added the ability to password protect public links,
+    and configure a duration after which the link will automatically expire.
+
+We are now working on the other requested features around sharing, including
+comments and reactions.

+ 0 - 41
docs/docs/photos/features/sharing.md

@@ -1,41 +0,0 @@
----
-title: Sharing
-description:
-    Ente allows you to share albums and collaborate with your loved ones
----
-
-# Sharing
-
-It is easy to share your albums on Ente, end-to-end encrypted.
-
-## Links
-
-You can create links to your albums by opening an album and clicking on the
-Share icon. They are publicly accessible by anyone who you share the link with.
-They don't need an app or account.
-
-These links can be password protected, or set to expire after a while.
-
-You can read more about the features supported by Links
-[here](https://ente.io/blog/powerful-links/).
-
-## Albums
-
-If your loved ones are already on Ente, you can share an album with their
-registered email address.
-
-If they are your partner, you can share your `Camera` folder on Android, or
-`Recents` on iOS. Whenever you click new photos, they will automatically be
-accessible on your partner's device.
-
-## Collaboration
-
-You can allow other Ente users to add photos to your album. This is a great way
-for you to build an album together with someone. You can control access to the
-same album - someone can be added as a `Collaborator`, while someone else as a
-`Viewer`.
-
-If you wish to collect photos from folks who are not Ente, you can do so with
-our Links. Simply tick the box that says "Allow uploads", and anyone who has
-access to the link will be able to add photos to your album.
-[Read more](https://ente.io/blog/collect-photos/)

+ 0 - 36
docs/docs/photos/features/watch-folder.md

@@ -1,36 +0,0 @@
----
-title: Watch folder
-description: Automatic syncing of certain folders in the Ente Photos desktop app
----
-
-# Watch folder
-
-_Automatic syncing_
-
-The Ente desktop app allows you to "watch" a folder on your computer for any
-changes, creating a one-way sync from your device to the Ente cloud. This is
-intended to automate your photo management and backup.
-
-## How to add Watch folders?
-
--   Click on the hamburger menu in the top left corner
--   Open the sidebar
--   Select _Watch Folders_
--   Choose _Add Watch Folders_
--   Pick the folder from your system that you want to add as a watched folder
-
-## How to remove Watch folders?
-
--   Click on the hamburger menu in the top left corner
--   Open the sidebar
--   Select _Watch Folders_
--   Click on the three dots menu next to the folders on the right side
--   Choose _Stop Watching_ from the menu
-
-# Tips:
-
--   You will get an option to choose whether to sync nested folders to a single
-    album or separate albums.
-
--   The app continuously monitors changes in the watched folder, such as the
-    addition or removal of files.

+ 68 - 0
docs/docs/photos/features/watch-folders.md

@@ -0,0 +1,68 @@
+---
+title: Watch folder
+description:
+    Automatic syncing of selected folders using the Ente Photos desktop app
+---
+
+# Watch folders
+
+The Ente desktop app allows you to "watch" a folder on your computer for any
+changes, creating a one-way background sync from folders on your computer to
+Ente albums. This is intended to automate your photo management and backup.
+
+By using the "Watch folders" option in the sidebar, you can tell the desktop app
+which are the folders that you want to watch for changes. The app will then
+automatically upload new files added to these folders to the corresponding ente
+album (it will also upload them initially). And if a file is deleted locally,
+then the corresponding Ente file will also be automatically moved to
+uncategorized.
+
+Paired with the option to run Ente automatically when your computer starts, this
+allows you to automate backups to ente's cloud.
+
+### Steps
+
+1. Press the **Watch folders** button in the sidebar. This will open up a dialog
+   where you can add and remove watched folders.
+
+2. To start watching a folder, press the **Add folder** button and select the
+   folder on your laptop that you want to watch for any changes. You can also
+   drag and drop the folder here.
+
+3. If the folder has nesting, you will see two options - **A single album** and
+   **Separate albums**.
+
+    - **Single album** will create a new Ente album with the same name as the
+      folder's name, and will then sync all the changes in the folder (and any
+      nested folders) to this single album.
+
+    - **Separate albums** will create separate albums for each nested folder of
+      the selected folder, and will then sync the changes in each nested folder
+      separately.
+
+    - For example, suppose you have a folder name `Photos` on your computer, and
+      inside that folder you have two nested folders named `New Year` and
+      `Summer`. In the single album mode, the app will create an Ente album
+      named "Photos" and put all the files from both `New Year` and `Summer`
+      there. In the separate album mode, the app will create two Ente albums,
+      "New Year" and "Summer", each only containing the respective files.
+
+    - In separate album mode, only nested folders that have at least one file
+      will result in the creation of a new album – empty folders (or folders
+      that only contain other folders) will be ignored.
+
+4. After choosing any of the above options, the folder will be initially synced
+   to ente's cloud and monitored for any changes. You can now close the dialog
+   and the sync will continue in background.
+
+5. When the app is syncing in the background it'll show a small progress status
+   in the bottom right. You can expand it to see more details if needed.
+
+6. You can stop watching any folder by clicking on the three dots next to the
+   watch folder entry, and then selecting **Stop watching**.
+
+> Note: In case you start a new upload while an existing sync is in progress,
+> the sync will be paused then and resumed when your upload is done.
+
+Some more details about the feature are in our
+[blog post](http://ente.io/blog/watch-folders) announcing it.

+ 6 - 5
docs/docs/photos/index.md

@@ -10,9 +10,10 @@ Photos. You can use it to safely and securely store your photos on the cloud.
 
 While security and privacy form the bedrock of Ente Photos, it is not at the
 cost of usability. The user interface is simple, and we are continuously working
-to make it even simpler. The goal is a product that can be used by people with
-all sorts of technical ability and background.
+to make it even simpler.
 
-These help docs are divided into three sections: Features, FAQ and
-Troubleshooting. Choose the relevant page from the sidebar menu, or use the
-search at the top.
+The goal is a product that can be used by people with all sorts of technical
+ability and background.
+
+These help docs are divided into four sections. Choose the relevant page from
+the sidebar menu, or use the search at the top.

BIN
docs/docs/photos/migration/export/export-1.png


BIN
docs/docs/photos/migration/export/export-2.png


BIN
docs/docs/photos/migration/export/export-3.png


BIN
docs/docs/photos/migration/export/export-4.png


+ 36 - 0
docs/docs/photos/migration/export/index.md

@@ -0,0 +1,36 @@
+---
+title: Exporting your data from Ente Photos
+description: Guide for exporting your photos out from Ente Photos
+---
+
+# Exporting your data out of Ente Photos
+
+Please follow the following simple steps to keep a local copy of the photos and
+videos you have uploaded to Ente.
+
+1. Sign in to [our desktop app](https://ente.io/download/desktop), if you
+   haven't done so already.
+
+2. Open the side bar, and select the option to **export data**.
+
+    ![Ente - Export data](export-1.png)
+
+3. Select the destination folder and click on **start**.
+
+    ![Ente - Select destination folder and start](export-2.png)
+
+4. Wait for the export to get completed.
+
+    ![Ente - Export in progress](export-3.png)
+
+5. Later on if you wish to sync newer files that were uploaded since the last
+   time you exported, simply select **export data** again and click on
+   **resync**.
+
+    ![Ente - Rexport](export-4.png)
+
+In case your download gets interrupted, Ente will resume from where it left off.
+Simply select **export data** again and click on **resync**.
+
+If you run into any issues during your data export, please reach out to
+[support@ente.io](mailto:support@ente.io) and we will be happy to help you!

+ 24 - 0
docs/docs/photos/migration/from-amazon-photos.md

@@ -0,0 +1,24 @@
+---
+title: Import from Amazon Photos
+description: Migrating your existing photos from Amazon Photos to Ente Photos
+---
+
+# Import from Amazon Photos
+
+Amazon Photos does not provide a way to export all of your photos and videos, or
+even albums with a single click.
+
+According to their
+[help desk article](https://www.amazon.com/gp/help/customer/display.html?nodeId=GVCELKY5JW77VE7W),
+you have to select and download photos individually.
+
+Once you've done that, simply drag and drop this folder into
+[our desktop app](https://ente.io/download/desktop), and Ente will take care of
+the rest.
+
+> Note: In case your uploads get interrupted, just drag and drop the folder into
+> the same album again, and we will ignore already backed up files and upload
+> just the rest.
+
+If you run into any issues during this migration, please reach out to
+[support@ente.io](mailto:support@ente.io) and we will be happy to help you!

BIN
docs/docs/photos/migration/from-apple-photos/export.png


+ 34 - 0
docs/docs/photos/migration/from-apple-photos/index.md

@@ -0,0 +1,34 @@
+---
+title: Import from Apple Photos
+description: Migrating your existing photos from Apple Photos to Ente Photos
+---
+
+# Import from Apple Photos
+
+The Apple Photos app provides an easy way download all your data.
+
+Select the files you want to export (`Command + A` to select them all), and
+click on `File` > `Export` > `Export Unmodified Originals`.
+
+![Apple Photos - Export](export.png)
+
+In the dialog that pops up, select File Name as `Sequential` and provide any
+prefix you'd like. This is to make sure that we combine the photo and video
+portions of your Live Photos correctly.
+
+![Apple Photos - Sequential file names](sequential.png)
+
+Finally, choose an export directory and confirm by clicking `Export Originals`.
+You will receive a notification from the app once your export is complete.
+
+Now simply drag and drop the downloaded folders into
+[our desktop app](https://ente.io/download/desktop) and grab a cup of coffee (or
+a good night's sleep, depending on the size of your library) while we handle the
+rest.
+
+> Note: In case your uploads get interrupted, just drag and drop the folders
+> into the same albums again, and we will ignore already backed up files and
+> upload just the rest.
+
+If you run into any issues during this migration, please reach out to
+[support@ente.io](mailto:support@ente.io) and we will be happy to help you!

BIN
docs/docs/photos/migration/from-apple-photos/sequential.png


Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä