Merge branch 'main' into f-droid

This commit is contained in:
ashilkn 2024-04-13 15:24:56 +05:30
commit d9a93ddad6
723 changed files with 10794 additions and 58951 deletions

View file

@ -85,7 +85,8 @@ jobs:
- name: Install dependencies for desktop build - name: Install dependencies for desktop build
run: | run: |
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y libsecret-1-dev libsodium-dev libwebkit2gtk-4.0-dev libfuse2 ninja-build libgtk-3-dev dpkg-dev pkg-config rpm libsqlite3-dev locate appindicator3-0.1 libappindicator3-dev libffi7 sudo apt-get install -y libsecret-1-dev libsodium-dev libwebkit2gtk-4.0-dev libfuse2 ninja-build libgtk-3-dev dpkg-dev pkg-config rpm libsqlite3-dev locate appindicator3-0.1 libappindicator3-dev libffi-dev libtiff5
sudo updatedb --localpaths='/usr/lib/x86_64-linux-gnu'
- name: Install appimagetool - name: Install appimagetool
run: | run: |

View file

@ -32,6 +32,8 @@ jobs:
image: server image: server
registry: ghcr.io registry: ghcr.io
enableBuildKit: true enableBuildKit: true
multiPlatform: true
platform: linux/amd64,linux/arm64,linux/arm/v7
buildArgs: GIT_COMMIT=${{ inputs.commit }} buildArgs: GIT_COMMIT=${{ inputs.commit }}
tags: ${{ inputs.commit }}, latest tags: ${{ inputs.commit }}, latest
username: ${{ github.actor }} username: ${{ github.actor }}

View file

@ -5,7 +5,7 @@ on:
branches: [main] branches: [main]
paths: paths:
# Run workflow when web's en-US/translation.json is changed # Run workflow when web's en-US/translation.json is changed
- "web/apps/photos/public/locales/en-US/translation.json" - "web/packages/next/locales/en-US/translation.json"
# Or the workflow itself is changed # Or the workflow itself is changed
- ".github/workflows/web-crowdin.yml" - ".github/workflows/web-crowdin.yml"
schedule: schedule:

View file

@ -24,7 +24,7 @@ jobs:
with: with:
node-version: 20 node-version: 20
cache: "yarn" cache: "yarn"
cache-dependency-path: "docs/yarn.lock" cache-dependency-path: "web/yarn.lock"
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install

View file

@ -24,7 +24,7 @@ jobs:
with: with:
node-version: 20 node-version: 20
cache: "yarn" cache: "yarn"
cache-dependency-path: "docs/yarn.lock" cache-dependency-path: "web/yarn.lock"
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install

View file

@ -24,7 +24,7 @@ jobs:
with: with:
node-version: 20 node-version: 20
cache: "yarn" cache: "yarn"
cache-dependency-path: "docs/yarn.lock" cache-dependency-path: "web/yarn.lock"
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install

View file

@ -24,7 +24,7 @@ jobs:
with: with:
node-version: 20 node-version: 20
cache: "yarn" cache: "yarn"
cache-dependency-path: "docs/yarn.lock" cache-dependency-path: "web/yarn.lock"
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install
@ -39,5 +39,5 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente projectName: ente
branch: deploy/payments branch: deploy/payments
directory: web/apps/payments/out directory: web/apps/payments/dist
wranglerVersion: "3" wranglerVersion: "3"

View file

@ -24,7 +24,7 @@ jobs:
with: with:
node-version: 20 node-version: 20
cache: "yarn" cache: "yarn"
cache-dependency-path: "docs/yarn.lock" cache-dependency-path: "web/yarn.lock"
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install

48
.github/workflows/web-deploy-staff.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: "Deploy (staff)"
on:
# Run on every push to main that changes web/apps/staff/
push:
branches: [main]
paths:
- "web/apps/staff/**"
- ".github/workflows/web-deploy-staff.yml"
# Also allow manually running the workflow
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup node and enable yarn caching
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
cache-dependency-path: "web/yarn.lock"
- name: Install dependencies
run: yarn install
- name: Build staff
run: yarn build:staff
- name: Publish staff
uses: cloudflare/pages-action@1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente
branch: deploy/staff
directory: web/apps/staff/dist
wranglerVersion: "3"

View file

@ -34,7 +34,7 @@ jobs:
with: with:
node-version: 20 node-version: 20
cache: "yarn" cache: "yarn"
cache-dependency-path: "docs/yarn.lock" cache-dependency-path: "web/yarn.lock"
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install
@ -88,7 +88,7 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
projectName: ente projectName: ente
branch: n-payments branch: n-payments
directory: web/apps/payments/out directory: web/apps/payments/dist
wranglerVersion: "3" wranglerVersion: "3"
- name: Build photos - name: Build photos

View file

@ -34,7 +34,7 @@ jobs:
with: with:
node-version: 20 node-version: 20
cache: "yarn" cache: "yarn"
cache-dependency-path: "docs/yarn.lock" cache-dependency-path: "web/yarn.lock"
- name: Install dependencies - name: Install dependencies
run: yarn install run: yarn install

View file

@ -59,7 +59,10 @@ See [docs/](docs/README.md) for how to edit these documents.
## Code contributions ## Code contributions
If you'd like to contribute code, it is best to start small. Code is a small aspect of community, and the ways mentioned above are more
important in helping us. But if you'd _really_ like to contribute code, it is
best to start small. Consider some well-scoped changes, say like adding more
[custom icons to auth](auth/docs/adding-icons.md).
Each of the individual product/platform specific directories in this repository Each of the individual product/platform specific directories in this repository
have instructions on setting up a dev environment and making changes. The issues have instructions on setting up a dev environment and making changes. The issues

View file

@ -0,0 +1,40 @@
Ente Auth helps you generate and store 2 step verification (2FA)
tokens on your mobile devices.
FEATURES
- Secure Backups
Auth provides end-to-end encrypted cloud backups so that you don't have to worry
about losing your tokens. We use the same protocols ente Photos uses to encrypt
and preserve your data.
- Multi Device Synchronization
Auth will automatically sync the 2FA tokens you add to your account, across all
your devices. Every new device you sign into will have access to these tokens.
- Web access
You can access your 2FA code from any web browser by visiting https://auth.ente.io .
- Offline Mode
Auth generates 2FA tokens offline, so your network connectivity will not get in
the way of your workflow.
- Import and Export Tokens
You can add tokens to Auth by one of the following methods:
1. Scanning a QR code
2. Manually entering (copy-pasting) a 2FA secret
3. Bulk importing from a file that contains a list of codes in the following format:
otpauth://totp/provider.com:you@email.com?secret=YOUR_SECRET
The codes maybe separated by new lines or commas.
You can also export the codes you have added to Auth, to an **unencrypted** text
file, that adheres to the above format.
SUPPORT
If you need help, please reach out to support@ente.io, and a human will get in touch with you.
If you have feature requests, please create an issue @ https://github.com/ente-io/ente

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -0,0 +1 @@
Auth is a FOSS authenticator app that provides end-to-end encrypted backups for your 2FA secrets.

View file

@ -0,0 +1 @@
Ente Auth

View file

@ -67,8 +67,6 @@ PODS:
- Toast - Toast
- local_auth_darwin (0.0.1): - local_auth_darwin (0.0.1):
- Flutter - Flutter
- local_auth_ios (0.0.1):
- Flutter
- move_to_background (0.0.1): - move_to_background (0.0.1):
- Flutter - Flutter
- MTBBarcodeScanner (5.0.11) - MTBBarcodeScanner (5.0.11)
@ -99,8 +97,6 @@ PODS:
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- smart_auth (0.0.1):
- Flutter
- sodium_libs (2.2.1): - sodium_libs (2.2.1):
- Flutter - Flutter
- sqflite (0.0.3): - sqflite (0.0.3):
@ -142,7 +138,6 @@ DEPENDENCIES:
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
- move_to_background (from `.symlinks/plugins/move_to_background/ios`) - move_to_background (from `.symlinks/plugins/move_to_background/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
@ -151,7 +146,6 @@ DEPENDENCIES:
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- smart_auth (from `.symlinks/plugins/smart_auth/ios`)
- sodium_libs (from `.symlinks/plugins/sodium_libs/ios`) - sodium_libs (from `.symlinks/plugins/sodium_libs/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
@ -202,8 +196,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/fluttertoast/ios" :path: ".symlinks/plugins/fluttertoast/ios"
local_auth_darwin: local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin" :path: ".symlinks/plugins/local_auth_darwin/darwin"
local_auth_ios:
:path: ".symlinks/plugins/local_auth_ios/ios"
move_to_background: move_to_background:
:path: ".symlinks/plugins/move_to_background/ios" :path: ".symlinks/plugins/move_to_background/ios"
package_info_plus: package_info_plus:
@ -220,8 +212,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation: shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
smart_auth:
:path: ".symlinks/plugins/smart_auth/ios"
sodium_libs: sodium_libs:
:path: ".symlinks/plugins/sodium_libs/ios" :path: ".symlinks/plugins/sodium_libs/ios"
sqflite: sqflite:
@ -245,11 +235,10 @@ SPEC CHECKSUMS:
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_local_authentication: 1172a4dd88f6306dadce067454e2c4caf07977bb flutter_local_authentication: 1172a4dd88f6306dadce067454e2c4caf07977bb
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98 local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
@ -264,7 +253,6 @@ SPEC CHECKSUMS:
SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
smart_auth: 4bedbc118723912d0e45a07e8ab34039c19e04f2
sodium_libs: 1faae17af662384acbd13e41867a0008cd2e2318 sodium_libs: 1faae17af662384acbd13e41867a0008cd2e2318
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
sqlite3: 73b7fc691fdc43277614250e04d183740cb15078 sqlite3: 73b7fc691fdc43277614250e04d183740cb15078

View file

@ -365,7 +365,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2; DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth; INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -439,7 +439,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2; DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth; INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -513,7 +513,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2; DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth; INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -587,7 +587,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2; DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth; INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -661,7 +661,7 @@
DEVELOPMENT_TEAM = 6Z68YJY9Q2; DEVELOPMENT_TEAM = 6Z68YJY9Q2;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = auth; INFOPLIST_KEY_CFBundleDisplayName = "Ente Auth";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View file

@ -78,14 +78,14 @@
"data": "Data", "data": "Data",
"importCodes": "Import codes", "importCodes": "Import codes",
"importTypePlainText": "Plain text", "importTypePlainText": "Plain text",
"importTypeEnteEncrypted": "ente Encrypted export", "importTypeEnteEncrypted": "Ente Encrypted export",
"passwordForDecryptingExport": "Password to decrypt export", "passwordForDecryptingExport": "Password to decrypt export",
"passwordEmptyError": "Password can not be empty", "passwordEmptyError": "Password can not be empty",
"importFromApp": "Import codes from {appName}", "importFromApp": "Import codes from {appName}",
"importGoogleAuthGuide": "Export your accounts from Google Authenticator to a QR code using the \"Transfer Accounts\" option. Then using another device, scan the QR code.\n\nTip: You can use your laptop's webcam to take a picture of the QR code.", "importGoogleAuthGuide": "Export your accounts from Google Authenticator to a QR code using the \"Transfer Accounts\" option. Then using another device, scan the QR code.\n\nTip: You can use your laptop's webcam to take a picture of the QR code.",
"importSelectJsonFile": "Select JSON file", "importSelectJsonFile": "Select JSON file",
"importSelectAppExport": "Select {appName} export file", "importSelectAppExport": "Select {appName} export file",
"importEnteEncGuide": "Select the encrypted JSON file exported from ente", "importEnteEncGuide": "Select the encrypted JSON file exported from Ente",
"importRaivoGuide": "Use the \"Export OTPs to Zip archive\" option in Raivo's Settings.\n\nExtract the zip file and import the JSON file.", "importRaivoGuide": "Use the \"Export OTPs to Zip archive\" option in Raivo's Settings.\n\nExtract the zip file and import the JSON file.",
"importBitwardenGuide": "Use the \"Export vault\" option within Bitwarden Tools and import the unencrypted JSON file.", "importBitwardenGuide": "Use the \"Export vault\" option within Bitwarden Tools and import the unencrypted JSON file.",
"importAegisGuide": "Use the \"Export the vault\" option in Aegis's Settings.\n\nIf your vault is encrypted, you will need to enter vault password to decrypt the vault.", "importAegisGuide": "Use the \"Export the vault\" option in Aegis's Settings.\n\nIf your vault is encrypted, you will need to enter vault password to decrypt the vault.",
@ -115,22 +115,22 @@
"copied": "Copied", "copied": "Copied",
"pleaseTryAgain": "Please try again", "pleaseTryAgain": "Please try again",
"existingUser": "Existing User", "existingUser": "Existing User",
"newUser": "New to ente", "newUser": "New to Ente",
"delete": "Delete", "delete": "Delete",
"enterYourPasswordHint": "Enter your password", "enterYourPasswordHint": "Enter your password",
"forgotPassword": "Forgot password", "forgotPassword": "Forgot password",
"oops": "Oops", "oops": "Oops",
"suggestFeatures": "Suggest features", "suggestFeatures": "Suggest features",
"faq": "FAQ", "faq": "FAQ",
"faq_q_1": "How secure is ente Auth?", "faq_q_1": "How secure is Auth?",
"faq_a_1": "All codes you backup via ente is stored end-to-end encrypted. This means only you can access your codes. Our apps are open source and our cryptography has been externally audited.", "faq_a_1": "All codes you backup via Auth is stored end-to-end encrypted. This means only you can access your codes. Our apps are open source and our cryptography has been externally audited.",
"faq_q_2": "Can I access my codes on desktop?", "faq_q_2": "Can I access my codes on desktop?",
"faq_a_2": "You can access your codes on the web @ auth.ente.io.", "faq_a_2": "You can access your codes on the web @ auth.ente.io.",
"faq_q_3": "How can I delete codes?", "faq_q_3": "How can I delete codes?",
"faq_a_3": "You can delete a code by swiping left on that item.", "faq_a_3": "You can delete a code by swiping left on that item.",
"faq_q_4": "How can I support this project?", "faq_q_4": "How can I support this project?",
"faq_a_4": "You can support the development of this project by subscribing to our Photos app @ ente.io.", "faq_a_4": "You can support the development of this project by subscribing to our Photos app @ ente.io.",
"faq_q_5": "How can I enable FaceID lock in ente Auth", "faq_q_5": "How can I enable FaceID lock in Auth",
"faq_a_5": "You can enable FaceID lock under Settings → Security → Lockscreen.", "faq_a_5": "You can enable FaceID lock under Settings → Security → Lockscreen.",
"somethingWentWrongMessage": "Something went wrong, please try again", "somethingWentWrongMessage": "Something went wrong, please try again",
"leaveFamily": "Leave family", "leaveFamily": "Leave family",
@ -350,7 +350,7 @@
"deleteCodeAuthMessage": "Authenticate to delete code", "deleteCodeAuthMessage": "Authenticate to delete code",
"showQRAuthMessage": "Authenticate to show QR code", "showQRAuthMessage": "Authenticate to show QR code",
"confirmAccountDeleteTitle": "Confirm account deletion", "confirmAccountDeleteTitle": "Confirm account deletion",
"confirmAccountDeleteMessage": "This account is linked to other ente apps, if you use any.\n\nYour uploaded data, across all ente apps, will be scheduled for deletion, and your account will be permanently deleted.", "confirmAccountDeleteMessage": "This account is linked to other Ente apps, if you use any.\n\nYour uploaded data, across all Ente apps, will be scheduled for deletion, and your account will be permanently deleted.",
"androidBiometricHint": "Verify identity", "androidBiometricHint": "Verify identity",
"@androidBiometricHint": { "@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -51,6 +51,7 @@ class _HomePageState extends State<HomePage> {
final scaffoldKey = GlobalKey<ScaffoldState>(); final scaffoldKey = GlobalKey<ScaffoldState>();
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode searchInputFocusNode = FocusNode();
bool _showSearchBox = false; bool _showSearchBox = false;
String _searchText = ""; String _searchText = "";
List<Code> _codes = []; List<Code> _codes = [];
@ -80,6 +81,17 @@ class _HomePageState extends State<HomePage> {
setState(() {}); setState(() {});
}); });
_showSearchBox = PreferenceService.instance.shouldAutoFocusOnSearchBar(); _showSearchBox = PreferenceService.instance.shouldAutoFocusOnSearchBar();
if (_showSearchBox) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
// https://github.com/flutter/flutter/issues/20706#issuecomment-646328652
FocusScope.of(context).unfocus();
Timer(const Duration(milliseconds: 1), () {
FocusScope.of(context).requestFocus(searchInputFocusNode);
});
},
);
}
} }
void _loadCodes() { void _loadCodes() {
@ -93,12 +105,22 @@ class _HomePageState extends State<HomePage> {
void _applyFilteringAndRefresh() { void _applyFilteringAndRefresh() {
if (_searchText.isNotEmpty && _showSearchBox) { if (_searchText.isNotEmpty && _showSearchBox) {
final String val = _searchText.toLowerCase(); final String val = _searchText.toLowerCase();
_filteredCodes = _codes // Prioritize issuer match above account for better UX while searching
.where( // for a specific TOTP for email providers. Searching for "emailProvider" like (gmail, proton) should
(element) => (element.account.toLowerCase().contains(val) || // show the email provider first instead of other accounts where protonmail
element.issuer.toLowerCase().contains(val)), // is the account name.
) final List<Code> issuerMatch = [];
.toList(); final List<Code> accountMatch = [];
for (final Code code in _codes) {
if (code.issuer.toLowerCase().contains(val)) {
issuerMatch.add(code);
} else if (code.account.toLowerCase().contains(val)) {
accountMatch.add(code);
}
}
_filteredCodes = issuerMatch;
_filteredCodes.addAll(accountMatch);
} else { } else {
_filteredCodes = _codes; _filteredCodes = _codes;
} }
@ -182,6 +204,7 @@ class _HomePageState extends State<HomePage> {
title: !_showSearchBox title: !_showSearchBox
? const Text('Ente Auth') ? const Text('Ente Auth')
: TextField( : TextField(
focusNode: searchInputFocusNode,
autofocus: _searchText.isEmpty, autofocus: _searchText.isEmpty,
controller: _textController, controller: _textController,
onChanged: (val) { onChanged: (val) {

View file

@ -4,8 +4,7 @@ import 'package:ente_auth/services/user_service.dart';
import 'package:ente_auth/ui/lifecycle_event_handler.dart'; import 'package:ente_auth/ui/lifecycle_event_handler.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:pinput/pin_put/pin_put.dart';
import 'package:pinput/pinput.dart';
class TwoFactorAuthenticationPage extends StatefulWidget { class TwoFactorAuthenticationPage extends StatefulWidget {
final String sessionID; final String sessionID;
@ -20,6 +19,10 @@ class TwoFactorAuthenticationPage extends StatefulWidget {
class _TwoFactorAuthenticationPageState class _TwoFactorAuthenticationPageState
extends State<TwoFactorAuthenticationPage> { extends State<TwoFactorAuthenticationPage> {
final _pinController = TextEditingController(); final _pinController = TextEditingController();
final _pinPutDecoration = BoxDecoration(
border: Border.all(color: const Color.fromRGBO(45, 194, 98, 1.0)),
borderRadius: BorderRadius.circular(15.0),
);
String _code = ""; String _code = "";
late LifecycleEventHandler _lifecycleEventHandler; late LifecycleEventHandler _lifecycleEventHandler;
@ -60,16 +63,6 @@ class _TwoFactorAuthenticationPageState
Widget _getBody() { Widget _getBody() {
final l10n = context.l10n; final l10n = context.l10n;
final pinPutDecoration = BoxDecoration(
border: Border.all(
color: Theme.of(context)
.inputDecorationTheme
.focusedBorder!
.borderSide
.color,
),
borderRadius: BorderRadius.circular(15.0),
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -86,31 +79,32 @@ class _TwoFactorAuthenticationPageState
const Padding(padding: EdgeInsets.all(32)), const Padding(padding: EdgeInsets.all(32)),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(40, 0, 40, 0), padding: const EdgeInsets.fromLTRB(40, 0, 40, 0),
child: Pinput( child: PinPut(
onSubmitted: (String code) { fieldsCount: 6,
onSubmit: (String code) {
_verifyTwoFactorCode(code); _verifyTwoFactorCode(code);
}, },
length: 6,
defaultPinTheme: const PinTheme(),
submittedPinTheme: PinTheme(
decoration: pinPutDecoration.copyWith(
borderRadius: BorderRadius.circular(20.0),
),
),
focusedPinTheme: PinTheme(
decoration: pinPutDecoration,
),
followingPinTheme: PinTheme(
decoration: pinPutDecoration.copyWith(
borderRadius: BorderRadius.circular(5.0),
),
),
onChanged: (String pin) { onChanged: (String pin) {
setState(() { setState(() {
_code = pin; _code = pin;
}); });
}, },
controller: _pinController, controller: _pinController,
submittedFieldDecoration: _pinPutDecoration.copyWith(
borderRadius: BorderRadius.circular(20.0),
),
selectedFieldDecoration: _pinPutDecoration,
followingFieldDecoration: _pinPutDecoration.copyWith(
borderRadius: BorderRadius.circular(5.0),
border: Border.all(
color: const Color.fromRGBO(45, 194, 98, 0.5),
),
),
inputDecoration: const InputDecoration(
focusedBorder: InputBorder.none,
border: InputBorder.none,
counterText: '',
),
autofocus: true, autofocus: true,
), ),
), ),

View file

@ -13,7 +13,6 @@
#include <gtk/gtk_plugin.h> #include <gtk/gtk_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h> #include <screen_retriever/screen_retriever_plugin.h>
#include <sentry_flutter/sentry_flutter_plugin.h> #include <sentry_flutter/sentry_flutter_plugin.h>
#include <smart_auth/smart_auth_plugin.h>
#include <sodium_libs/sodium_libs_plugin.h> #include <sodium_libs/sodium_libs_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h> #include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <tray_manager/tray_manager_plugin.h> #include <tray_manager/tray_manager_plugin.h>
@ -42,9 +41,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) sentry_flutter_registrar = g_autoptr(FlPluginRegistrar) sentry_flutter_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "SentryFlutterPlugin");
sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar); sentry_flutter_plugin_register_with_registrar(sentry_flutter_registrar);
g_autoptr(FlPluginRegistrar) smart_auth_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SmartAuthPlugin");
smart_auth_plugin_register_with_registrar(smart_auth_registrar);
g_autoptr(FlPluginRegistrar) sodium_libs_registrar = g_autoptr(FlPluginRegistrar) sodium_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "SodiumLibsPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "SodiumLibsPlugin");
sodium_libs_plugin_register_with_registrar(sodium_libs_registrar); sodium_libs_plugin_register_with_registrar(sodium_libs_registrar);

View file

@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
gtk gtk
screen_retriever screen_retriever
sentry_flutter sentry_flutter
smart_auth
sodium_libs sodium_libs
sqlite3_flutter_libs sqlite3_flutter_libs
tray_manager tray_manager

View file

@ -25,3 +25,4 @@ startup_notify: false
# - libcurl.so.4 # - libcurl.so.4
include: include:
- libffi.so.7 - libffi.so.7
- libtiff.so.5

View file

@ -9,7 +9,7 @@ url: https://github.com/ente-io/ente
display_name: Auth display_name: Auth
dependencies: requires:
- libsqlite3x - libsqlite3x
- webkit2gtk-4.0 - webkit2gtk-4.0
- libsodium - libsodium

View file

@ -20,7 +20,6 @@ import screen_retriever
import sentry_flutter import sentry_flutter
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
import smart_auth
import sodium_libs import sodium_libs
import sqflite import sqflite
import sqlite3_flutter_libs import sqlite3_flutter_libs
@ -44,7 +43,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin")) SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SmartAuthPlugin.register(with: registry.registrar(forPlugin: "SmartAuthPlugin"))
SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin")) SodiumLibsPlugin.register(with: registry.registrar(forPlugin: "SodiumLibsPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))

View file

@ -1109,10 +1109,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: pinput name: pinput
sha256: a92b55ecf9c25d1b9e100af45905385d5bc34fc9b6b04177a9e82cb88fe4d805 sha256: "27eb69042f75755bdb6544f6e79a50a6ed09d6e97e2d75c8421744df1e392949"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "1.2.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -1334,14 +1334,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
smart_auth:
dependency: transitive
description:
name: smart_auth
sha256: a25229b38c02f733d0a4e98d941b42bed91a976cb589e934895e60ccfa674cf6
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sodium: sodium:
dependency: transitive dependency: transitive
description: description:
@ -1551,14 +1543,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.2" version: "2.2.2"
universal_platform:
dependency: transitive
description:
name: universal_platform
sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc
url: "https://pub.dev"
source: hosted
version: "1.0.0+1"
url_launcher: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -1,6 +1,6 @@
name: ente_auth name: ente_auth
description: ente two-factor authenticator description: ente two-factor authenticator
version: 2.0.50+250 version: 2.0.55+255
publish_to: none publish_to: none
environment: environment:
@ -75,7 +75,7 @@ dependencies:
password_strength: ^0.2.0 password_strength: ^0.2.0
path: ^1.8.3 path: ^1.8.3
path_provider: ^2.0.11 path_provider: ^2.0.11
pinput: ^3.0.1 pinput: ^1.2.2
pointycastle: ^3.7.3 pointycastle: ^3.7.3
privacy_screen: ^0.0.6 privacy_screen: ^0.0.6
protobuf: ^3.0.0 protobuf: ^3.0.0

View file

@ -16,7 +16,6 @@
#include <screen_retriever/screen_retriever_plugin.h> #include <screen_retriever/screen_retriever_plugin.h>
#include <sentry_flutter/sentry_flutter_plugin.h> #include <sentry_flutter/sentry_flutter_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h> #include <share_plus/share_plus_windows_plugin_c_api.h>
#include <smart_auth/smart_auth_plugin.h>
#include <sodium_libs/sodium_libs_plugin_c_api.h> #include <sodium_libs/sodium_libs_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h> #include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <tray_manager/tray_manager_plugin.h> #include <tray_manager/tray_manager_plugin.h>
@ -44,8 +43,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("SentryFlutterPlugin")); registry->GetRegistrarForPlugin("SentryFlutterPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar( SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
SmartAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SmartAuthPlugin"));
SodiumLibsPluginCApiRegisterWithRegistrar( SodiumLibsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SodiumLibsPluginCApi")); registry->GetRegistrarForPlugin("SodiumLibsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar( Sqlite3FlutterLibsPluginRegisterWithRegistrar(

View file

@ -13,7 +13,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
screen_retriever screen_retriever
sentry_flutter sentry_flutter
share_plus share_plus
smart_auth
sodium_libs sodium_libs
sqlite3_flutter_libs sqlite3_flutter_libs
tray_manager tray_manager

View file

@ -7,11 +7,6 @@ module.exports = {
// "plugin:@typescript-eslint/strict-type-checked", // "plugin:@typescript-eslint/strict-type-checked",
// "plugin:@typescript-eslint/stylistic-type-checked", // "plugin:@typescript-eslint/stylistic-type-checked",
], ],
/* Temporarily disable some rules
Enhancement: Remove me */
rules: {
"no-unused-vars": "off",
},
/* Temporarily add a global /* Temporarily add a global
Enhancement: Remove me */ Enhancement: Remove me */
globals: { globals: {

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
desktop/build/icon.icns Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,5 +1,9 @@
# Dependencies # Dependencies
- [Electron](#electron)
- [Dev dependencies](#dev)
- [Functionality](#functionality)
## Electron ## Electron
[Electron](https://www.electronjs.org) is a cross-platform (Linux, Windows, [Electron](https://www.electronjs.org) is a cross-platform (Linux, Windows,
@ -61,34 +65,34 @@ Electron process. This allows us to directly use the output produced by
### Others ### Others
* [any-shell-escape](https://github.com/boazy/any-shell-escape) is for - [any-shell-escape](https://github.com/boazy/any-shell-escape) is for
escaping shell commands before we execute them (e.g. say when invoking the escaping shell commands before we execute them (e.g. say when invoking the
embedded ffmpeg CLI). embedded ffmpeg CLI).
* [auto-launch](https://github.com/Teamwork/node-auto-launch) is for - [auto-launch](https://github.com/Teamwork/node-auto-launch) is for
automatically starting our app on login, if the user so wishes. automatically starting our app on login, if the user so wishes.
* [electron-store](https://github.com/sindresorhus/electron-store) is used for - [electron-store](https://github.com/sindresorhus/electron-store) is used for
persisting user preferences and other arbitrary data. persisting user preferences and other arbitrary data.
## Dev ## Dev
See [web/docs/dependencies#DX](../../web/docs/dependencies.md#dev) for the See [web/docs/dependencies#dev](../../web/docs/dependencies.md#dev) for the
general development experience related dependencies like TypeScript etc, which general development experience related dependencies like TypeScript etc, which
are similar to that in the web code. are similar to that in the web code.
Some extra ones specific to the code here are: Some extra ones specific to the code here are:
* [concurrently](https://github.com/open-cli-tools/concurrently) for spawning - [concurrently](https://github.com/open-cli-tools/concurrently) for spawning
parallel tasks when we do `yarn dev`. parallel tasks when we do `yarn dev`.
* [shx](https://github.com/shelljs/shx) for providing a portable way to use Unix - [shx](https://github.com/shelljs/shx) for providing a portable way to use
commands in our `package.json` scripts. This allows us to use the same Unix commands in our `package.json` scripts. This allows us to use the same
commands (like `ln`) across different platforms like Linux and Windows. commands (like `ln`) across different platforms like Linux and Windows.
## Functionality ## Functionality
### Conversion ### Format conversion
The main tool we use is for arbitrary conversions is FFMPEG. To bundle a The main tool we use is for arbitrary conversions is FFMPEG. To bundle a
(platform specific) static binary of ffmpeg with our app, we use (platform specific) static binary of ffmpeg with our app, we use
@ -104,20 +108,23 @@ resources (`build`) folder. This is used for thumbnail generation on Linux.
On macOS, we use the `sips` CLI tool for conversion, but that is already On macOS, we use the `sips` CLI tool for conversion, but that is already
available on the host machine, and is not bundled with our app. available on the host machine, and is not bundled with our app.
### AI/ML
[onnxruntime-node](https://github.com/Microsoft/onnxruntime) is used as the
AI/ML runtime. It powers both natural language searches (using CLIP) and face
detection (using YOLO).
[jpeg-js](https://github.com/jpeg-js/jpeg-js#readme) is used for decoding JPEG
data into raw RGB bytes before passing it to ONNX.
html-entities is used by the bundled clip-bpe-ts tokenizer for CLIP.
### Watch Folders ### Watch Folders
[chokidar](https://github.com/paulmillr/chokidar) is used as a file system [chokidar](https://github.com/paulmillr/chokidar) is used as a file system
watcher for the watch folders functionality. watcher for the watch folders functionality.
### AI/ML ### ZIP
* [onnxruntime-node](https://github.com/Microsoft/onnxruntime)
* html-entities is used by the bundled clip-bpe-ts.
* GGML binaries are bundled
* We also use [jpeg-js](https://github.com/jpeg-js/jpeg-js#readme) for
conversion of all images to JPEG before processing.
## ZIP
[node-stream-zip](https://github.com/antelle/node-stream-zip) is used for [node-stream-zip](https://github.com/antelle/node-stream-zip) is used for
reading of large ZIP files (e.g. during imports of Google Takeout ZIPs). reading of large ZIP files (e.g. during imports of Google Takeout ZIPs).

View file

@ -1,5 +1,15 @@
appId: io.ente.bhari-frame appId: io.ente.bhari-frame
artifactName: ${productName}-${version}-${arch}.${ext} artifactName: ${productName}-${version}-${arch}.${ext}
files:
- app/**/*
- out
extraFiles:
- from: build
to: resources
win:
target:
- target: nsis
arch: [x64, arm64]
nsis: nsis:
deleteAppDataOnUninstall: true deleteAppDataOnUninstall: true
linux: linux:
@ -19,11 +29,4 @@ mac:
arch: [universal] arch: [universal]
category: public.app-category.photography category: public.app-category.photography
hardenedRuntime: true hardenedRuntime: true
x64ArchFiles: Contents/Resources/ggmlclip-mac
afterSign: electron-builder-notarize afterSign: electron-builder-notarize
extraFiles:
- from: build
to: resources
files:
- app/**/*
- out

View file

@ -11,7 +11,7 @@
"build-main:quick": "tsc && electron-builder --dir --config.compression=store --config.mac.identity=null", "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 && shx rm -f out && shx ln -sf ../web/apps/photos/out out", "build-renderer": "cd ../web && yarn install && yarn build:photos && cd ../desktop && shx rm -f out && shx ln -sf ../web/apps/photos/out out",
"build:quick": "yarn build-renderer && yarn build-main:quick", "build:quick": "yarn build-renderer && yarn build-main:quick",
"dev": "concurrently --names 'main,rndr' \"yarn dev-main\" \"yarn dev-renderer\"", "dev": "concurrently --kill-others --success first --names 'main,rndr' \"yarn dev-main\" \"yarn dev-renderer\"",
"dev-main": "tsc && electron app/main.js", "dev-main": "tsc && electron app/main.js",
"dev-renderer": "cd ../web && yarn install && yarn dev:photos", "dev-renderer": "cd ../web && yarn install && yarn dev:photos",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",

View file

@ -1,17 +0,0 @@
import { logError } from "../main/log";
import { keysStore } from "../stores/keys.store";
import { safeStorageStore } from "../stores/safeStorage.store";
import { uploadStatusStore } from "../stores/upload.store";
import { watchStore } from "../stores/watch.store";
export const clearElectronStore = () => {
try {
uploadStatusStore.clear();
keysStore.clear();
safeStorageStore.clear();
watchStore.clear();
} catch (e) {
logError(e, "error while clearing electron store");
throw e;
}
};

View file

@ -1,28 +0,0 @@
import { safeStorage } from "electron/main";
import { logError } from "../main/log";
import { safeStorageStore } from "../stores/safeStorage.store";
export async function setEncryptionKey(encryptionKey: string) {
try {
const encryptedKey: Buffer =
await safeStorage.encryptString(encryptionKey);
const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
safeStorageStore.set("encryptionKey", b64EncryptedKey);
} catch (e) {
logError(e, "setEncryptionKey failed");
throw e;
}
}
export async function getEncryptionKey(): Promise<string> {
try {
const b64EncryptedKey = safeStorageStore.get("encryptionKey");
if (b64EncryptedKey) {
const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
return await safeStorage.decryptString(keyBuffer);
}
} catch (e) {
logError(e, "getEncryptionKey failed");
throw e;
}
}

View file

@ -1,41 +0,0 @@
import { getElectronFile } from "../services/fs";
import {
getElectronFilesFromGoogleZip,
getSavedFilePaths,
} from "../services/upload";
import { uploadStatusStore } from "../stores/upload.store";
import { ElectronFile, FILE_PATH_TYPE } from "../types/ipc";
export const getPendingUploads = async () => {
const filePaths = getSavedFilePaths(FILE_PATH_TYPE.FILES);
const zipPaths = getSavedFilePaths(FILE_PATH_TYPE.ZIPS);
const collectionName = uploadStatusStore.get("collectionName");
let files: ElectronFile[] = [];
let type: FILE_PATH_TYPE;
if (zipPaths.length) {
type = FILE_PATH_TYPE.ZIPS;
for (const zipPath of zipPaths) {
files = [
...files,
...(await getElectronFilesFromGoogleZip(zipPath)),
];
}
const pendingFilePaths = new Set(filePaths);
files = files.filter((file) => pendingFilePaths.has(file.path));
} else if (filePaths.length) {
type = FILE_PATH_TYPE.FILES;
files = await Promise.all(filePaths.map(getElectronFile));
}
return {
files,
collectionName,
type,
};
};
export {
getElectronFilesFromGoogleZip,
setToUploadCollection,
setToUploadFiles,
} from "../services/upload";

View file

@ -1,26 +0,0 @@
/**
* [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
*/
export const CustomErrors = {
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}`,
};

View file

@ -8,53 +8,56 @@
* *
* https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process * https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
*/ */
import { app, BrowserWindow, Menu } from "electron/main"; import { nativeImage } from "electron";
import { app, BrowserWindow, Menu, Tray } from "electron/main";
import serveNextAt from "next-electron-server"; import serveNextAt from "next-electron-server";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { import {
addAllowOriginHeader, addAllowOriginHeader,
createWindow,
handleDockIconHideOnAutoLaunch,
handleDownloads, handleDownloads,
handleExternalLinks, handleExternalLinks,
logStartupBanner,
setupMacWindowOnDockIconClick,
setupTrayItem,
} from "./main/init"; } from "./main/init";
import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc"; import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
import log, { initLogging } from "./main/log"; import log, { initLogging } from "./main/log";
import { createApplicationMenu } from "./main/menu"; import { createApplicationMenu, createTrayContextMenu } from "./main/menu";
import { setupAutoUpdater } from "./main/services/app-update";
import autoLauncher from "./main/services/autoLauncher";
import { initWatcher } from "./main/services/chokidar";
import { userPreferences } from "./main/stores/user-preferences";
import { isDev } from "./main/util"; import { isDev } from "./main/util";
import { setupAutoUpdater } from "./services/appUpdater";
import { initWatcher } from "./services/chokidar";
let appIsQuitting = false;
let updateIsAvailable = false;
export const isAppQuitting = (): boolean => {
return appIsQuitting;
};
export const setIsAppQuitting = (value: boolean): void => {
appIsQuitting = value;
};
export const isUpdateAvailable = (): boolean => {
return updateIsAvailable;
};
export const setIsUpdateAvailable = (value: boolean): void => {
updateIsAvailable = value;
};
/** /**
* The URL where the renderer HTML is being served from. * The URL where the renderer HTML is being served from.
*/ */
export const rendererURL = "next://app"; export const rendererURL = "next://app";
/**
* We want to hide our window instead of closing it when the user presses the
* cross button on the window.
*
* > This is because there is 1. a perceptible initial window creation time for
* > our app, and 2. because the long running processes like export and watch
* > folders are tied to the lifetime of the window and otherwise won't run in
* > the background.
*
* Intercepting the window close event and using that to instead hide it is
* easy, however that prevents the actual app quit to stop working (since the
* window never gets closed).
*
* So to achieve our original goal (hide window instead of closing) without
* disabling expected app quits, we keep a flag, and we turn it on when we're
* part of the quit sequence. When this flag is on, we bypass the code that
* prevents the window from being closed.
*/
let shouldAllowWindowClose = false;
export const allowWindowClose = (): void => {
shouldAllowWindowClose = true;
};
/** /**
* next-electron-server allows up to directly use the output of `next build` in * next-electron-server allows up to directly use the output of `next build` in
* production mode and `next dev` in development mode, whilst keeping the rest * production mode and `next dev` in development mode, whilst keeping the rest
@ -68,33 +71,145 @@ export const rendererURL = "next://app";
* For more details, see this comparison: * For more details, see this comparison:
* https://github.com/HaNdTriX/next-electron-server/issues/5 * https://github.com/HaNdTriX/next-electron-server/issues/5
*/ */
const setupRendererServer = () => { const setupRendererServer = () => serveNextAt(rendererURL);
serveNextAt(rendererURL);
};
function enableSharedArrayBufferSupport() { /**
app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer"); * Log a standard startup banner.
} *
* This helps us identify app starts and other environment details in the logs.
*/
const logStartupBanner = () => {
const version = isDev ? "dev" : app.getVersion();
log.info(`Starting ente-photos-desktop ${version}`);
const platform = process.platform;
const osRelease = os.release();
const systemVersion = process.getSystemVersion();
log.info("Running on", { platform, osRelease, systemVersion });
};
/** /**
* [Note: Increased disk cache for the desktop app] * [Note: Increased disk cache for the desktop app]
* *
* Set the "disk-cache-size" command line flag to ask the Chromium process to * 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 * 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, * the web based caching mechanisms on both the web and the desktop app, just
* just ask the embedded Chromium to be a bit more generous in disk usage when * ask the embedded Chromium to be a bit more generous in disk usage when
* running as the desktop app. * running as the desktop app.
* *
* The size we provide is in bytes. We set it to a large value, 5 GB (5 * 1024 * * The size we provide is in bytes.
* 1024 * 1024 = 5368709120)
* https://www.electronjs.org/docs/latest/api/command-line-switches#--disk-cache-sizesize * 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 * Note that increasing the disk cache size does not guarantee that Chromium
* will respect in verbatim, it uses its own heuristics atop this hint. * 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 * https://superuser.com/questions/378991/what-is-chrome-default-cache-size-limit/1577693#1577693
*
* See also: [Note: Caching files].
*/ */
const increaseDiskCache = () => { const increaseDiskCache = () =>
app.commandLine.appendSwitch("disk-cache-size", "5368709120"); app.commandLine.appendSwitch(
"disk-cache-size",
`${5 * 1024 * 1024 * 1024}`, // 5 GB
);
/**
* Create an return the {@link BrowserWindow} that will form our app's UI.
*
* This window will show the HTML served from {@link rendererURL}.
*/
const createMainWindow = async () => {
// Create the main window. This'll show our web content.
const window = new BrowserWindow({
webPreferences: {
preload: path.join(app.getAppPath(), "preload.js"),
sandbox: true,
},
// The color to show in the window until the web content gets loaded.
// See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property
backgroundColor: "black",
// We'll show it conditionally depending on `wasAutoLaunched` later.
show: false,
});
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
if (wasAutoLaunched) {
// Don't automatically show the app's window if we were auto-launched.
// On macOS, also hide the dock icon on macOS.
if (process.platform == "darwin") app.dock.hide();
} else {
// Show our window (maximizing it) otherwise.
window.maximize();
}
window.loadURL(rendererURL);
// Open the DevTools automatically when running in dev mode
if (isDev) window.webContents.openDevTools();
window.webContents.on("render-process-gone", (_, details) => {
log.error(`render-process-gone: ${details}`);
window.webContents.reload();
});
window.webContents.on("unresponsive", () => {
log.error(
"Main window's webContents are unresponsive, will restart the renderer process",
);
window.webContents.forcefullyCrashRenderer();
});
window.on("close", (event) => {
if (!shouldAllowWindowClose) {
event.preventDefault();
window.hide();
}
return false;
});
window.on("hide", () => {
// On macOS, when hiding the window also hide the app's icon in the dock
// if the user has selected the Settings > Hide dock icon checkbox.
if (process.platform == "darwin" && userPreferences.get("hideDockIcon"))
app.dock.hide();
});
window.on("show", () => {
if (process.platform == "darwin") app.dock.show();
});
// Let ipcRenderer know when mainWindow is in the foreground so that it can
// in turn inform the renderer process.
window.on("focus", () => window.webContents.send("mainWindowFocus"));
return window;
};
/**
* Add an icon for our app in the system tray.
*
* For example, these are the small icons that appear on the top right of the
* screen in the main menu bar on macOS.
*/
const setupTrayItem = (mainWindow: BrowserWindow) => {
// There are a total of 6 files corresponding to this tray icon.
//
// On macOS, use template images (filename needs to end with "Template.ext")
// https://www.electronjs.org/docs/latest/api/native-image#template-image-macos
//
// And for each (template or otherwise), there are 3 "retina" variants
// https://www.electronjs.org/docs/latest/api/native-image#high-resolution-image
const iconName =
process.platform == "darwin"
? "taskbar-icon-Template.png"
: "taskbar-icon.png";
const trayImgPath = path.join(
isDev ? "build" : process.resourcesPath,
iconName,
);
const trayIcon = nativeImage.createFromPath(trayImgPath);
const tray = new Tray(trayIcon);
tray.setToolTip("Ente Photos");
tray.setContextMenu(createTrayContextMenu(mainWindow));
}; };
/** /**
@ -126,13 +241,6 @@ const deleteLegacyDiskCacheDirIfExists = async () => {
} }
}; };
function setupAppEventEmitter(mainWindow: BrowserWindow) {
// fire event when mainWindow is in foreground
mainWindow.on("focus", () => {
mainWindow.webContents.send("app-in-foreground");
});
}
const main = () => { const main = () => {
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
@ -140,21 +248,18 @@ const main = () => {
return; return;
} }
let mainWindow: BrowserWindow; let mainWindow: BrowserWindow | undefined;
initLogging(); initLogging();
setupRendererServer(); setupRendererServer();
handleDockIconHideOnAutoLaunch(); logStartupBanner();
increaseDiskCache(); increaseDiskCache();
enableSharedArrayBufferSupport();
app.on("second-instance", () => { app.on("second-instance", () => {
// Someone tried to run a second instance, we should focus our window. // Someone tried to run a second instance, we should focus our window.
if (mainWindow) { if (mainWindow) {
mainWindow.show(); mainWindow.show();
if (mainWindow.isMinimized()) { if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.restore();
}
mainWindow.focus(); mainWindow.focus();
} }
}); });
@ -163,11 +268,9 @@ const main = () => {
// //
// Note that some Electron APIs can only be used after this event occurs. // Note that some Electron APIs can only be used after this event occurs.
app.on("ready", async () => { app.on("ready", async () => {
logStartupBanner(); mainWindow = await createMainWindow();
mainWindow = await createWindow();
const watcher = initWatcher(mainWindow); const watcher = initWatcher(mainWindow);
setupTrayItem(mainWindow); setupTrayItem(mainWindow);
setupMacWindowOnDockIconClick();
Menu.setApplicationMenu(await createApplicationMenu(mainWindow)); Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
attachIPCHandlers(); attachIPCHandlers();
attachFSWatchIPCHandlers(watcher); attachFSWatchIPCHandlers(watcher);
@ -175,18 +278,21 @@ const main = () => {
handleDownloads(mainWindow); handleDownloads(mainWindow);
handleExternalLinks(mainWindow); handleExternalLinks(mainWindow);
addAllowOriginHeader(mainWindow); addAllowOriginHeader(mainWindow);
setupAppEventEmitter(mainWindow);
try { try {
deleteLegacyDiskCacheDirIfExists(); deleteLegacyDiskCacheDirIfExists();
} catch (e) { } catch (e) {
// Log but otherwise ignore errors during non-critical startup // Log but otherwise ignore errors during non-critical startup
// actions // actions.
log.error("Ignoring startup error", e); log.error("Ignoring startup error", e);
} }
}); });
app.on("before-quit", () => setIsAppQuitting(true)); // This is a macOS only event. Show our window when the user activates the
// app, e.g. by clicking on its dock icon.
app.on("activate", () => mainWindow?.show());
app.on("before-quit", allowWindowClose);
}; };
main(); main();

View file

@ -1,8 +1,8 @@
import { dialog } from "electron/main"; import { dialog } from "electron/main";
import path from "node:path"; import path from "node:path";
import { getDirFilePaths, getElectronFile } from "../services/fs";
import { getElectronFilesFromGoogleZip } from "../services/upload";
import type { ElectronFile } from "../types/ipc"; import type { ElectronFile } from "../types/ipc";
import { getDirFilePaths, getElectronFile } from "./services/fs";
import { getElectronFilesFromGoogleZip } from "./services/upload";
export const selectDirectory = async () => { export const selectDirectory = async () => {
const result = await dialog.showOpenDialog({ const result = await dialog.showOpenDialog({

View file

@ -1,97 +1,7 @@
import { app, BrowserWindow, nativeImage, Tray } from "electron"; import { BrowserWindow, app, shell } from "electron";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { isAppQuitting, rendererURL } from "../main"; import { rendererURL } from "../main";
import autoLauncher from "../services/autoLauncher";
import { getHideDockIconPreference } from "../services/userPreference";
import { isPlatform } from "../utils/common/platform";
import log from "./log";
import { createTrayContextMenu } from "./menu";
import { isDev } from "./util";
/**
* 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 () => {
// Create the main window. This'll show our web content.
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(app.getAppPath(), "preload.js"),
},
// The color to show in the window until the web content gets loaded.
// See: https://www.electronjs.org/docs/latest/api/browser-window#setting-the-backgroundcolor-property
backgroundColor: "black",
// We'll show it conditionally depending on `wasAutoLaunched` later.
show: false,
});
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
if (wasAutoLaunched) {
// Keep the macOS dock icon hidden if we were auto launched.
if (process.platform == "darwin") app.dock.hide();
} else {
// Show our window (maximizing it) if this is not an auto-launch on
// login.
mainWindow.maximize();
}
mainWindow.loadURL(rendererURL);
// Open the DevTools automatically when running in dev mode
if (isDev) mainWindow.webContents.openDevTools();
mainWindow.webContents.on("render-process-gone", (_, details) => {
log.error(`render-process-gone: ${details}`);
mainWindow.webContents.reload();
});
mainWindow.webContents.on("unresponsive", () => {
log.error("webContents unresponsive");
mainWindow.webContents.forcefullyCrashRenderer();
});
mainWindow.on("close", function (event) {
if (!isAppQuitting()) {
event.preventDefault();
mainWindow.hide();
}
return false;
});
mainWindow.on("hide", () => {
// On macOS, also hide the app's icon in the dock if the user has
// selected the Settings > Hide dock icon checkbox.
const shouldHideDockIcon = getHideDockIconPreference();
if (process.platform == "darwin" && shouldHideDockIcon) {
app.dock.hide();
}
});
mainWindow.on("show", () => {
if (process.platform == "darwin") app.dock.show();
});
return mainWindow;
};
export async function handleUpdates(mainWindow: BrowserWindow) {}
export const setupTrayItem = (mainWindow: BrowserWindow) => {
const iconName = isPlatform("mac")
? "taskbar-icon-Template.png"
: "taskbar-icon.png";
const trayImgPath = path.join(
isDev ? "build" : process.resourcesPath,
iconName,
);
const trayIcon = nativeImage.createFromPath(trayImgPath);
const tray = new Tray(trayIcon);
tray.setToolTip("ente");
tray.setContextMenu(createTrayContextMenu(mainWindow));
};
export function handleDownloads(mainWindow: BrowserWindow) { export function handleDownloads(mainWindow: BrowserWindow) {
mainWindow.webContents.session.on("will-download", (_, item) => { mainWindow.webContents.session.on("will-download", (_, item) => {
@ -104,7 +14,7 @@ export function handleDownloads(mainWindow: BrowserWindow) {
export function handleExternalLinks(mainWindow: BrowserWindow) { export function handleExternalLinks(mainWindow: BrowserWindow) {
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(rendererURL)) { if (!url.startsWith(rendererURL)) {
require("electron").shell.openExternal(url); shell.openExternal(url);
return { action: "deny" }; return { action: "deny" };
} else { } else {
return { action: "allow" }; return { action: "allow" };
@ -132,33 +42,6 @@ export function getUniqueSavePath(filename: string, directory: string): string {
return uniqueFileSavePath; return uniqueFileSavePath;
} }
export function setupMacWindowOnDockIconClick() {
app.on("activate", function () {
const windows = BrowserWindow.getAllWindows();
// we allow only one window
windows[0].show();
});
}
export async function handleDockIconHideOnAutoLaunch() {
const shouldHideDockIcon = getHideDockIconPreference();
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
if (isPlatform("mac") && shouldHideDockIcon && wasAutoLaunched) {
app.dock.hide();
}
}
export function logStartupBanner() {
const version = isDev ? "dev" : app.getVersion();
log.info(`Hello from ente-photos-desktop ${version}`);
const platform = process.platform;
const osRelease = os.release();
const systemVersion = process.getSystemVersion();
log.info("Running on", { platform, osRelease, systemVersion });
}
function lowerCaseHeaders(responseHeaders: Record<string, string[]>) { function lowerCaseHeaders(responseHeaders: Record<string, string[]>) {
const headers: Record<string, string[]> = {}; const headers: Record<string, string[]> = {};
for (const key of Object.keys(responseHeaders)) { for (const key of Object.keys(responseHeaders)) {

View file

@ -10,43 +10,7 @@
import type { FSWatcher } from "chokidar"; import type { FSWatcher } from "chokidar";
import { ipcMain } from "electron/main"; import { ipcMain } from "electron/main";
import { clearElectronStore } from "../api/electronStore"; import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc";
import { getEncryptionKey, setEncryptionKey } from "../api/safeStorage";
import {
getElectronFilesFromGoogleZip,
getPendingUploads,
setToUploadCollection,
setToUploadFiles,
} from "../api/upload";
import {
appVersion,
muteUpdateNotification,
skipAppUpdate,
updateAndRestart,
} from "../services/appUpdater";
import {
computeImageEmbedding,
computeTextEmbedding,
} from "../services/clipService";
import { runFFmpegCmd } from "../services/ffmpeg";
import { getDirFiles } from "../services/fs";
import {
convertToJPEG,
generateImageThumbnail,
} from "../services/imageProcessor";
import {
addWatchMapping,
getWatchMappings,
removeWatchMapping,
updateWatchMappingIgnoredFiles,
updateWatchMappingSyncedFiles,
} from "../services/watch";
import type {
ElectronFile,
FILE_PATH_TYPE,
Model,
WatchMapping,
} from "../types/ipc";
import { import {
selectDirectory, selectDirectory,
showUploadDirsDialog, showUploadDirsDialog,
@ -66,6 +30,38 @@ import {
saveStreamToDisk, saveStreamToDisk,
} from "./fs"; } from "./fs";
import { logToDisk } from "./log"; import { logToDisk } from "./log";
import {
appVersion,
skipAppUpdate,
updateAndRestart,
updateOnNextRestart,
} from "./services/app-update";
import { runFFmpegCmd } from "./services/ffmpeg";
import { getDirFiles } from "./services/fs";
import {
convertToJPEG,
generateImageThumbnail,
} from "./services/imageProcessor";
import { clipImageEmbedding, clipTextEmbedding } from "./services/ml-clip";
import { detectFaces, faceEmbedding } from "./services/ml-face";
import {
clearStores,
encryptionKey,
saveEncryptionKey,
} from "./services/store";
import {
getElectronFilesFromGoogleZip,
getPendingUploads,
setToUploadCollection,
setToUploadFiles,
} from "./services/upload";
import {
addWatchMapping,
getWatchMappings,
removeWatchMapping,
updateWatchMappingIgnoredFiles,
updateWatchMappingSyncedFiles,
} from "./services/watch";
import { openDirectory, openLogDirectory } from "./util"; import { openDirectory, openLogDirectory } from "./util";
/** /**
@ -91,35 +87,33 @@ export const attachIPCHandlers = () => {
// - General // - General
ipcMain.handle("appVersion", (_) => appVersion()); ipcMain.handle("appVersion", () => appVersion());
ipcMain.handle("openDirectory", (_, dirPath) => openDirectory(dirPath)); ipcMain.handle("openDirectory", (_, dirPath) => openDirectory(dirPath));
ipcMain.handle("openLogDirectory", (_) => openLogDirectory()); ipcMain.handle("openLogDirectory", () => openLogDirectory());
// See [Note: Catching exception during .send/.on] // See [Note: Catching exception during .send/.on]
ipcMain.on("logToDisk", (_, message) => logToDisk(message)); ipcMain.on("logToDisk", (_, message) => logToDisk(message));
ipcMain.on("clear-electron-store", (_) => { ipcMain.on("clearStores", () => clearStores());
clearElectronStore();
});
ipcMain.handle("setEncryptionKey", (_, encryptionKey) => ipcMain.handle("saveEncryptionKey", (_, encryptionKey) =>
setEncryptionKey(encryptionKey), saveEncryptionKey(encryptionKey),
); );
ipcMain.handle("getEncryptionKey", (_) => getEncryptionKey()); ipcMain.handle("encryptionKey", () => encryptionKey());
// - App update // - App update
ipcMain.on("update-and-restart", (_) => updateAndRestart()); ipcMain.on("updateAndRestart", () => updateAndRestart());
ipcMain.on("skip-app-update", (_, version) => skipAppUpdate(version)); ipcMain.on("updateOnNextRestart", (_, version) =>
updateOnNextRestart(version),
ipcMain.on("mute-update-notification", (_, version) =>
muteUpdateNotification(version),
); );
ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version));
// - Conversion // - Conversion
ipcMain.handle("convertToJPEG", (_, fileData, filename) => ipcMain.handle("convertToJPEG", (_, fileData, filename) =>
@ -145,25 +139,31 @@ export const attachIPCHandlers = () => {
// - ML // - ML
ipcMain.handle( ipcMain.handle("clipImageEmbedding", (_, jpegImageData: Uint8Array) =>
"computeImageEmbedding", clipImageEmbedding(jpegImageData),
(_, model: Model, imageData: Uint8Array) =>
computeImageEmbedding(model, imageData),
); );
ipcMain.handle("computeTextEmbedding", (_, model: Model, text: string) => ipcMain.handle("clipTextEmbedding", (_, text: string) =>
computeTextEmbedding(model, text), clipTextEmbedding(text),
);
ipcMain.handle("detectFaces", (_, input: Float32Array) =>
detectFaces(input),
);
ipcMain.handle("faceEmbedding", (_, input: Float32Array) =>
faceEmbedding(input),
); );
// - File selection // - File selection
ipcMain.handle("selectDirectory", (_) => selectDirectory()); ipcMain.handle("selectDirectory", () => selectDirectory());
ipcMain.handle("showUploadFilesDialog", (_) => showUploadFilesDialog()); ipcMain.handle("showUploadFilesDialog", () => showUploadFilesDialog());
ipcMain.handle("showUploadDirsDialog", (_) => showUploadDirsDialog()); ipcMain.handle("showUploadDirsDialog", () => showUploadDirsDialog());
ipcMain.handle("showUploadZipDialog", (_) => showUploadZipDialog()); ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog());
// - FS // - FS
@ -177,12 +177,12 @@ export const attachIPCHandlers = () => {
ipcMain.handle( ipcMain.handle(
"saveStreamToDisk", "saveStreamToDisk",
(_, path: string, fileStream: ReadableStream<any>) => (_, path: string, fileStream: ReadableStream) =>
saveStreamToDisk(path, fileStream), saveStreamToDisk(path, fileStream),
); );
ipcMain.handle("saveFileToDisk", (_, path: string, file: any) => ipcMain.handle("saveFileToDisk", (_, path: string, contents: string) =>
saveFileToDisk(path, file), saveFileToDisk(path, contents),
); );
ipcMain.handle("readTextFile", (_, path: string) => readTextFile(path)); ipcMain.handle("readTextFile", (_, path: string) => readTextFile(path));
@ -203,7 +203,7 @@ export const attachIPCHandlers = () => {
// - Upload // - Upload
ipcMain.handle("getPendingUploads", (_) => getPendingUploads()); ipcMain.handle("getPendingUploads", () => getPendingUploads());
ipcMain.handle( ipcMain.handle(
"setToUploadFiles", "setToUploadFiles",
@ -252,7 +252,7 @@ export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
removeWatchMapping(watcher, folderPath), removeWatchMapping(watcher, folderPath),
); );
ipcMain.handle("getWatchMappings", (_) => getWatchMappings()); ipcMain.handle("getWatchMappings", () => getWatchMappings());
ipcMain.handle( ipcMain.handle(
"updateWatchMappingSyncedFiles", "updateWatchMappingSyncedFiles",

View file

@ -15,10 +15,20 @@ import { isDev } from "./util";
*/ */
export const initLogging = () => { export const initLogging = () => {
log.transports.file.fileName = "ente.log"; log.transports.file.fileName = "ente.log";
log.transports.file.maxSize = 50 * 1024 * 1024; // 50MB; log.transports.file.maxSize = 50 * 1024 * 1024; // 50 MB
log.transports.file.format = "[{y}-{m}-{d}T{h}:{i}:{s}{z}] {text}"; log.transports.file.format = "[{y}-{m}-{d}T{h}:{i}:{s}{z}] {text}";
log.transports.console.level = false; log.transports.console.level = false;
// Log unhandled errors and promise rejections.
log.errorHandler.startCatching({
onError: ({ error, errorName }) => {
logError(errorName, error);
// Prevent the default electron-log actions (e.g. showing a dialog)
// from getting triggered.
return false;
},
});
}; };
/** /**
@ -31,25 +41,7 @@ export const logToDisk = (message: string) => {
log.info(`[rndr] ${message}`); log.info(`[rndr] ${message}`);
}; };
export const logError = logErrorSentry; const logError = (message: string, e?: unknown) => {
/** 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 });
}
}
const logError1 = (message: string, e?: unknown) => {
if (!e) { if (!e) {
logError_(message); logError_(message);
return; return;
@ -78,11 +70,14 @@ const logInfo = (...params: any[]) => {
.map((p) => (typeof p == "string" ? p : util.inspect(p))) .map((p) => (typeof p == "string" ? p : util.inspect(p)))
.join(" "); .join(" ");
log.info(`[main] ${message}`); log.info(`[main] ${message}`);
if (isDev) console.log(message); if (isDev) console.log(`[info] ${message}`);
}; };
const logDebug = (param: () => any) => { const logDebug = (param: () => any) => {
if (isDev) console.log(`[debug] ${util.inspect(param())}`); if (isDev) {
const p = param();
console.log(`[debug] ${typeof p == "string" ? p : util.inspect(p)}`);
}
}; };
/** /**
@ -98,12 +93,13 @@ export default {
* Log an error message with an optional associated error object. * Log an error message with an optional associated error object.
* *
* {@link e} is generally expected to be an `instanceof Error` but it can be * {@link e} is generally expected to be an `instanceof Error` but it can be
* any arbitrary object that we obtain, say, when in a try-catch handler. * any arbitrary object that we obtain, say, when in a try-catch handler (in
* JavaScript any arbitrary value can be thrown).
* *
* The log is written to disk. In development builds, the log is also * The log is written to disk. In development builds, the log is also
* printed to the (Node.js process') console. * printed to the main (Node.js) process console.
*/ */
error: logError1, error: logError,
/** /**
* Log a message. * Log a message.
* *
@ -111,7 +107,7 @@ export default {
* arbitrary number of arbitrary parameters that it then serializes. * arbitrary number of arbitrary parameters that it then serializes.
* *
* The log is written to disk. In development builds, the log is also * The log is written to disk. In development builds, the log is also
* printed to the (Node.js process') console. * printed to the main (Node.js) process console.
*/ */
info: logInfo, info: logInfo,
/** /**
@ -121,11 +117,11 @@ export default {
* function to call to get the log message instead of directly taking the * function to call to get the log message instead of directly taking the
* message. The provided function will only be called in development builds. * message. The provided function will only be called in development builds.
* *
* The function can return an arbitrary value which is serialied before * The function can return an arbitrary value which is serialized before
* being logged. * being logged.
* *
* This log is not written to disk. It is printed to the (Node.js process') * This log is NOT written to disk. And it is printed to the main (Node.js)
* console only on development builds. * process console, but only on development builds.
*/ */
debug: logDebug, debug: logDebug,
}; };

View file

@ -5,13 +5,10 @@ import {
MenuItemConstructorOptions, MenuItemConstructorOptions,
shell, shell,
} from "electron"; } from "electron";
import { setIsAppQuitting } from "../main"; import { allowWindowClose } from "../main";
import { forceCheckForUpdateAndNotify } from "../services/appUpdater"; import { forceCheckForAppUpdates } from "./services/app-update";
import autoLauncher from "../services/autoLauncher"; import autoLauncher from "./services/autoLauncher";
import { import { userPreferences } from "./stores/user-preferences";
getHideDockIconPreference,
setHideDockIconPreference,
} from "../services/userPreference";
import { openLogDirectory } from "./util"; import { openLogDirectory } from "./util";
/** Create and return the entries in the app's main menu bar */ /** Create and return the entries in the app's main menu bar */
@ -21,13 +18,12 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
// Whenever the menu is redrawn the current value of these variables is used // Whenever the menu is redrawn the current value of these variables is used
// to set the checked state for the various settings checkboxes. // to set the checked state for the various settings checkboxes.
let isAutoLaunchEnabled = await autoLauncher.isEnabled(); let isAutoLaunchEnabled = await autoLauncher.isEnabled();
let shouldHideDockIcon = getHideDockIconPreference(); let shouldHideDockIcon = userPreferences.get("hideDockIcon");
const macOSOnly = (options: MenuItemConstructorOptions[]) => const macOSOnly = (options: MenuItemConstructorOptions[]) =>
process.platform == "darwin" ? options : []; process.platform == "darwin" ? options : [];
const handleCheckForUpdates = () => const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
forceCheckForUpdateAndNotify(mainWindow);
const handleViewChangelog = () => const handleViewChangelog = () =>
shell.openExternal( shell.openExternal(
@ -40,7 +36,9 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
}; };
const toggleHideDockIcon = () => { const toggleHideDockIcon = () => {
setHideDockIconPreference(!shouldHideDockIcon); // Persist
userPreferences.set("hideDockIcon", !shouldHideDockIcon);
// And update the in-memory state
shouldHideDockIcon = !shouldHideDockIcon; shouldHideDockIcon = !shouldHideDockIcon;
}; };
@ -54,7 +52,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
return Menu.buildFromTemplate([ return Menu.buildFromTemplate([
{ {
label: "ente", label: "Ente Photos",
submenu: [ submenu: [
...macOSOnly([ ...macOSOnly([
{ {
@ -156,7 +154,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
{ type: "separator" }, { type: "separator" },
{ label: "Bring All to Front", role: "front" }, { label: "Bring All to Front", role: "front" },
{ type: "separator" }, { type: "separator" },
{ label: "Ente", role: "window" }, { label: "Ente Photos", role: "window" },
]), ]),
], ],
}, },
@ -197,7 +195,7 @@ export const createTrayContextMenu = (mainWindow: BrowserWindow) => {
}; };
const handleClose = () => { const handleClose = () => {
setIsAppQuitting(true); allowWindowClose();
app.quit(); app.quit();
}; };

View file

@ -0,0 +1,94 @@
import { compareVersions } from "compare-versions";
import { app, BrowserWindow } from "electron";
import { default as electronLog } from "electron-log";
import { autoUpdater } from "electron-updater";
import { allowWindowClose } from "../../main";
import { AppUpdateInfo } from "../../types/ipc";
import log from "../log";
import { userPreferences } from "../stores/user-preferences";
export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
autoUpdater.logger = electronLog;
autoUpdater.autoDownload = false;
const oneDay = 1 * 24 * 60 * 60 * 1000;
setInterval(() => checkForUpdatesAndNotify(mainWindow), oneDay);
checkForUpdatesAndNotify(mainWindow);
};
/**
* Check for app update check ignoring any previously saved skips / mutes.
*/
export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => {
userPreferences.delete("skipAppVersion");
userPreferences.delete("muteUpdateNotificationVersion");
checkForUpdatesAndNotify(mainWindow);
};
const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
const updateCheckResult = await autoUpdater.checkForUpdates();
if (!updateCheckResult) {
log.error("Failed to check for updates");
return;
}
const { version } = updateCheckResult.updateInfo;
log.debug(() => `Update check found version ${version}`);
if (compareVersions(version, app.getVersion()) <= 0) {
log.debug(() => "Skipping update, already at latest version");
return;
}
if (version === userPreferences.get("skipAppVersion")) {
log.info(`User chose to skip version ${version}`);
return;
}
const mutedVersion = userPreferences.get("muteUpdateNotificationVersion");
if (version === mutedVersion) {
log.info(`User has muted update notifications for version ${version}`);
return;
}
const showUpdateDialog = (updateInfo: AppUpdateInfo) =>
mainWindow.webContents.send("appUpdateAvailable", updateInfo);
log.debug(() => "Attempting auto update");
autoUpdater.downloadUpdate();
let timeout: NodeJS.Timeout;
const fiveMinutes = 5 * 60 * 1000;
autoUpdater.on("update-downloaded", () => {
timeout = setTimeout(
() => showUpdateDialog({ autoUpdatable: true, version }),
fiveMinutes,
);
});
autoUpdater.on("error", (error) => {
clearTimeout(timeout);
log.error("Auto update failed", error);
showUpdateDialog({ autoUpdatable: false, version });
});
};
/**
* Return the version of the desktop app
*
* The return value is of the form `v1.2.3`.
*/
export const appVersion = () => `v${app.getVersion()}`;
export const updateAndRestart = () => {
log.info("Restarting the app to apply update");
allowWindowClose();
autoUpdater.quitAndInstall();
};
export const updateOnNextRestart = (version: string) =>
userPreferences.set("muteUpdateNotificationVersion", version);
export const skipAppUpdate = (version: string) =>
userPreferences.set("skipAppVersion", version);

View file

@ -1,5 +1,5 @@
import { AutoLauncherClient } from "../types/main"; import { AutoLauncherClient } from "../../types/main";
import { isPlatform } from "../utils/common/platform"; import { isPlatform } from "../platform";
import linuxAndWinAutoLauncher from "./autoLauncherClients/linuxAndWinAutoLauncher"; import linuxAndWinAutoLauncher from "./autoLauncherClients/linuxAndWinAutoLauncher";
import macAutoLauncher from "./autoLauncherClients/macAutoLauncher"; import macAutoLauncher from "./autoLauncherClients/macAutoLauncher";

View file

@ -1,6 +1,6 @@
import AutoLaunch from "auto-launch"; import AutoLaunch from "auto-launch";
import { app } from "electron"; import { app } from "electron";
import { AutoLauncherClient } from "../../types/main"; import { AutoLauncherClient } from "../../../types/main";
const LAUNCHED_AS_HIDDEN_FLAG = "hidden"; const LAUNCHED_AS_HIDDEN_FLAG = "hidden";

View file

@ -1,5 +1,5 @@
import { app } from "electron"; import { app } from "electron";
import { AutoLauncherClient } from "../../types/main"; import { AutoLauncherClient } from "../../../types/main";
class MacAutoLauncher implements AutoLauncherClient { class MacAutoLauncher implements AutoLauncherClient {
async isEnabled() { async isEnabled() {

View file

@ -1,9 +1,9 @@
import chokidar from "chokidar"; import chokidar from "chokidar";
import { BrowserWindow } from "electron"; import { BrowserWindow } from "electron";
import path from "path"; import path from "path";
import { logError } from "../main/log"; import log from "../log";
import { getWatchMappings } from "../services/watch";
import { getElectronFile } from "./fs"; import { getElectronFile } from "./fs";
import { getWatchMappings } from "./watch";
/** /**
* Convert a file system {@link filePath} that uses the local system specific * Convert a file system {@link filePath} that uses the local system specific
@ -38,7 +38,7 @@ export function initWatcher(mainWindow: BrowserWindow) {
); );
}) })
.on("error", (error) => { .on("error", (error) => {
logError(error, "error while watching files"); log.error("Error while watching files", error);
}); });
return watcher; return watcher;

View file

@ -1,12 +1,11 @@
import pathToFfmpeg from "ffmpeg-static"; import pathToFfmpeg from "ffmpeg-static";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { CustomErrors } from "../constants/errors"; import { ElectronFile } from "../../types/ipc";
import { writeStream } from "../main/fs"; import { writeStream } from "../fs";
import log from "../main/log"; import log from "../log";
import { execAsync } from "../main/util"; import { generateTempFilePath, getTempDirPath } from "../temp";
import { ElectronFile } from "../types/ipc"; import { execAsync } from "../util";
import { generateTempFilePath, getTempDirPath } from "../utils/temp";
const INPUT_PATH_PLACEHOLDER = "INPUT"; const INPUT_PATH_PLACEHOLDER = "INPUT";
const FFMPEG_PLACEHOLDER = "FFMPEG"; const FFMPEG_PLACEHOLDER = "FFMPEG";
@ -146,7 +145,7 @@ const promiseWithTimeout = async <T>(
} = { current: null }; } = { current: null };
const rejectOnTimeout = new Promise<null>((_, reject) => { const rejectOnTimeout = new Promise<null>((_, reject) => {
timeoutRef.current = setTimeout( timeoutRef.current = setTimeout(
() => reject(Error(CustomErrors.WAIT_TIME_EXCEEDED)), () => reject(new Error("Operation timed out")),
timeout, timeout,
); );
}); });

View file

@ -2,8 +2,8 @@ import StreamZip from "node-stream-zip";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { logError } from "../main/log"; import { ElectronFile } from "../../types/ipc";
import { ElectronFile } from "../types/ipc"; import log from "../log";
const FILE_STREAM_CHUNK_SIZE: number = 4 * 1024 * 1024; const FILE_STREAM_CHUNK_SIZE: number = 4 * 1024 * 1024;
@ -115,7 +115,9 @@ export const getZipFileStream = async (
const inProgress = { const inProgress = {
current: false, current: false,
}; };
// eslint-disable-next-line no-unused-vars
let resolveObj: (value?: any) => void = null; let resolveObj: (value?: any) => void = null;
// eslint-disable-next-line no-unused-vars
let rejectObj: (reason?: any) => void = null; let rejectObj: (reason?: any) => void = null;
stream.on("readable", () => { stream.on("readable", () => {
try { try {
@ -179,7 +181,7 @@ export const getZipFileStream = async (
controller.close(); controller.close();
} }
} catch (e) { } catch (e) {
logError(e, "readableStream pull failed"); log.error("Failed to pull from readableStream", e);
controller.close(); controller.close();
} }
}, },

View file

@ -1,13 +1,12 @@
import { existsSync } from "fs"; import { existsSync } from "fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "path"; import path from "path";
import { CustomErrors } from "../constants/errors"; import { CustomErrors, ElectronFile } from "../../types/ipc";
import { writeStream } from "../main/fs"; import { writeStream } from "../fs";
import { logError, logErrorSentry } from "../main/log"; import log from "../log";
import { execAsync, isDev } from "../main/util"; import { isPlatform } from "../platform";
import { ElectronFile } from "../types/ipc"; import { generateTempFilePath } from "../temp";
import { isPlatform } from "../utils/common/platform"; import { execAsync, isDev } from "../util";
import { generateTempFilePath } from "../utils/temp";
import { deleteTempFile } from "./ffmpeg"; import { deleteTempFile } from "./ffmpeg";
const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK"; const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK";
@ -103,18 +102,21 @@ async function convertToJPEG_(
return new Uint8Array(await fs.readFile(tempOutputFilePath)); return new Uint8Array(await fs.readFile(tempOutputFilePath));
} catch (e) { } catch (e) {
logErrorSentry(e, "failed to convert heic"); log.error("Failed to convert HEIC", e);
throw e; throw e;
} finally { } finally {
try { try {
await fs.rm(tempInputFilePath, { force: true }); await fs.rm(tempInputFilePath, { force: true });
} catch (e) { } catch (e) {
logErrorSentry(e, "failed to remove tempInputFile"); log.error(`Failed to remove tempInputFile ${tempInputFilePath}`, e);
} }
try { try {
await fs.rm(tempOutputFilePath, { force: true }); await fs.rm(tempOutputFilePath, { force: true });
} catch (e) { } catch (e) {
logErrorSentry(e, "failed to remove tempOutputFile"); log.error(
`Failed to remove tempOutputFile ${tempOutputFilePath}`,
e,
);
} }
} }
} }
@ -150,7 +152,7 @@ function constructConvertCommand(
}, },
); );
} else { } else {
throw Error(CustomErrors.INVALID_OS(process.platform)); throw new Error(`Unsupported OS ${process.platform}`);
} }
return convertCmd; return convertCmd;
} }
@ -187,7 +189,7 @@ export async function generateImageThumbnail(
try { try {
await deleteTempFile(inputFilePath); await deleteTempFile(inputFilePath);
} catch (e) { } catch (e) {
logError(e, "failed to deleteTempFile"); log.error(`Failed to deleteTempFile ${inputFilePath}`, e);
} }
} }
} }
@ -217,13 +219,16 @@ async function generateImageThumbnail_(
} while (thumbnail.length > maxSize && quality > MIN_QUALITY); } while (thumbnail.length > maxSize && quality > MIN_QUALITY);
return thumbnail; return thumbnail;
} catch (e) { } catch (e) {
logErrorSentry(e, "generate image thumbnail failed"); log.error("Failed to generate image thumbnail", e);
throw e; throw e;
} finally { } finally {
try { try {
await fs.rm(tempOutputFilePath, { force: true }); await fs.rm(tempOutputFilePath, { force: true });
} catch (e) { } catch (e) {
logErrorSentry(e, "failed to remove tempOutputFile"); log.error(
`Failed to remove tempOutputFile ${tempOutputFilePath}`,
e,
);
} }
} }
} }
@ -283,7 +288,7 @@ function constructThumbnailGenerationCommand(
return cmdPart; return cmdPart;
}); });
} else { } else {
throw Error(CustomErrors.INVALID_OS(process.platform)); throw new Error(`Unsupported OS ${process.platform}`);
} }
return thumbnailGenerationCmd; return thumbnailGenerationCmd;
} }

View file

@ -0,0 +1,248 @@
/**
* @file Compute CLIP embeddings for images and text.
*
* The embeddings are computed using ONNX runtime, with CLIP as the model.
*
* @see `web/apps/photos/src/services/clip-service.ts` for more details.
*/
import { existsSync } from "fs";
import jpeg from "jpeg-js";
import fs from "node:fs/promises";
import * as ort from "onnxruntime-node";
import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
import { CustomErrors } from "../../types/ipc";
import { writeStream } from "../fs";
import log from "../log";
import { generateTempFilePath } from "../temp";
import { deleteTempFile } from "./ffmpeg";
import {
createInferenceSession,
downloadModel,
modelPathDownloadingIfNeeded,
modelSavePath,
} from "./ml";
const textModelName = "clip-text-vit-32-uint8.onnx";
const textModelByteSize = 64173509; // 61.2 MB
const imageModelName = "clip-image-vit-32-float32.onnx";
const imageModelByteSize = 351468764; // 335.2 MB
let activeImageModelDownload: Promise<string> | undefined;
const imageModelPathDownloadingIfNeeded = async () => {
try {
if (activeImageModelDownload) {
log.info("Waiting for CLIP image model download to finish");
await activeImageModelDownload;
} else {
activeImageModelDownload = modelPathDownloadingIfNeeded(
imageModelName,
imageModelByteSize,
);
return await activeImageModelDownload;
}
} finally {
activeImageModelDownload = undefined;
}
};
let textModelDownloadInProgress = false;
/* TODO(MR): use the generic method. Then we can remove the exports for the
internal details functions that we use here */
const textModelPathDownloadingIfNeeded = async () => {
if (textModelDownloadInProgress)
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
const modelPath = modelSavePath(textModelName);
if (!existsSync(modelPath)) {
log.info("CLIP text model not found, downloading");
textModelDownloadInProgress = true;
downloadModel(modelPath, textModelName)
.catch((e) => {
// log but otherwise ignore
log.error("CLIP text model download failed", e);
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
const localFileSize = (await fs.stat(modelPath)).size;
if (localFileSize !== textModelByteSize) {
log.error(
`CLIP text model size ${localFileSize} does not match the expected size, downloading again`,
);
textModelDownloadInProgress = true;
downloadModel(modelPath, textModelName)
.catch((e) => {
// log but otherwise ignore
log.error("CLIP text model download failed", e);
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
}
}
return modelPath;
};
let imageSessionPromise: Promise<any> | undefined;
const onnxImageSession = async () => {
if (!imageSessionPromise) {
imageSessionPromise = (async () => {
const modelPath = await imageModelPathDownloadingIfNeeded();
return createInferenceSession(modelPath);
})();
}
return imageSessionPromise;
};
let _textSession: any = null;
const onnxTextSession = async () => {
if (!_textSession) {
const modelPath = await textModelPathDownloadingIfNeeded();
_textSession = await createInferenceSession(modelPath);
}
return _textSession;
};
export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
const tempFilePath = await generateTempFilePath("");
const imageStream = new Response(jpegImageData.buffer).body;
await writeStream(tempFilePath, imageStream);
try {
return await clipImageEmbedding_(tempFilePath);
} finally {
await deleteTempFile(tempFilePath);
}
};
const clipImageEmbedding_ = async (jpegFilePath: string) => {
const imageSession = await onnxImageSession();
const t1 = Date.now();
const rgbData = await getRGBData(jpegFilePath);
const feeds = {
input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
log.debug(
() =>
`onnx/clip image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
);
const imageEmbedding = results["output"].data; // Float32Array
return normalizeEmbedding(imageEmbedding);
};
const getRGBData = async (jpegFilePath: string) => {
const jpegData = await fs.readFile(jpegFilePath);
const rawImageData = jpeg.decode(jpegData, {
useTArray: true,
formatAsRGBA: false,
});
const nx: number = rawImageData.width;
const ny: number = rawImageData.height;
const inputImage: Uint8Array = rawImageData.data;
const nx2: number = 224;
const ny2: number = 224;
const totalSize: number = 3 * nx2 * ny2;
const result: number[] = Array(totalSize).fill(0);
const scale: number = Math.max(nx, ny) / 224;
const nx3: number = Math.round(nx / scale);
const ny3: number = Math.round(ny / scale);
const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
const std: number[] = [0.26862954, 0.26130258, 0.27577711];
for (let y = 0; y < ny3; y++) {
for (let x = 0; x < nx3; x++) {
for (let c = 0; c < 3; c++) {
// Linear interpolation
const sx: number = (x + 0.5) * scale - 0.5;
const sy: number = (y + 0.5) * scale - 0.5;
const x0: number = Math.max(0, Math.floor(sx));
const y0: number = Math.max(0, Math.floor(sy));
const x1: number = Math.min(x0 + 1, nx - 1);
const y1: number = Math.min(y0 + 1, ny - 1);
const dx: number = sx - x0;
const dy: number = sy - y0;
const j00: number = 3 * (y0 * nx + x0) + c;
const j01: number = 3 * (y0 * nx + x1) + c;
const j10: number = 3 * (y1 * nx + x0) + c;
const j11: number = 3 * (y1 * nx + x1) + c;
const v00: number = inputImage[j00];
const v01: number = inputImage[j01];
const v10: number = inputImage[j10];
const v11: number = inputImage[j11];
const v0: number = v00 * (1 - dx) + v01 * dx;
const v1: number = v10 * (1 - dx) + v11 * dx;
const v: number = v0 * (1 - dy) + v1 * dy;
const v2: number = Math.min(Math.max(Math.round(v), 0), 255);
// createTensorWithDataList is dumb compared to reshape and
// hence has to be given with one channel after another
const i: number = y * nx3 + x + (c % 3) * 224 * 224;
result[i] = (v2 / 255 - mean[c]) / std[c];
}
}
}
return result;
};
const normalizeEmbedding = (embedding: Float32Array) => {
let normalization = 0;
for (let index = 0; index < embedding.length; index++) {
normalization += embedding[index] * embedding[index];
}
const sqrtNormalization = Math.sqrt(normalization);
for (let index = 0; index < embedding.length; index++) {
embedding[index] = embedding[index] / sqrtNormalization;
}
return embedding;
};
let _tokenizer: Tokenizer = null;
const getTokenizer = () => {
if (!_tokenizer) {
_tokenizer = new Tokenizer();
}
return _tokenizer;
};
export const clipTextEmbedding = async (text: string) => {
const imageSession = await onnxTextSession();
const t1 = Date.now();
const tokenizer = getTokenizer();
const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
const feeds = {
input: new ort.Tensor("int32", tokenizedText, [1, 77]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
log.debug(
() =>
`onnx/clip text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
);
const textEmbedding = results["output"].data;
return normalizeEmbedding(textEmbedding);
};

View file

@ -0,0 +1,108 @@
/**
* @file Various face recognition related tasks.
*
* - Face detection with the YOLO model.
* - Face embedding with the MobileFaceNet model.
*
* The runtime used is ONNX.
*/
import * as ort from "onnxruntime-node";
import log from "../log";
import { createInferenceSession, modelPathDownloadingIfNeeded } from "./ml";
const faceDetectionModelName = "yolov5s_face_640_640_dynamic.onnx";
const faceDetectionModelByteSize = 30762872; // 29.3 MB
const faceEmbeddingModelName = "mobilefacenet_opset15.onnx";
const faceEmbeddingModelByteSize = 5286998; // 5 MB
let activeFaceDetectionModelDownload: Promise<string> | undefined;
const faceDetectionModelPathDownloadingIfNeeded = async () => {
try {
if (activeFaceDetectionModelDownload) {
log.info("Waiting for face detection model download to finish");
await activeFaceDetectionModelDownload;
} else {
activeFaceDetectionModelDownload = modelPathDownloadingIfNeeded(
faceDetectionModelName,
faceDetectionModelByteSize,
);
return await activeFaceDetectionModelDownload;
}
} finally {
activeFaceDetectionModelDownload = undefined;
}
};
let _faceDetectionSession: Promise<ort.InferenceSession> | undefined;
const faceDetectionSession = async () => {
if (!_faceDetectionSession) {
_faceDetectionSession =
faceDetectionModelPathDownloadingIfNeeded().then((modelPath) =>
createInferenceSession(modelPath),
);
}
return _faceDetectionSession;
};
let activeFaceEmbeddingModelDownload: Promise<string> | undefined;
const faceEmbeddingModelPathDownloadingIfNeeded = async () => {
try {
if (activeFaceEmbeddingModelDownload) {
log.info("Waiting for face embedding model download to finish");
await activeFaceEmbeddingModelDownload;
} else {
activeFaceEmbeddingModelDownload = modelPathDownloadingIfNeeded(
faceEmbeddingModelName,
faceEmbeddingModelByteSize,
);
return await activeFaceEmbeddingModelDownload;
}
} finally {
activeFaceEmbeddingModelDownload = undefined;
}
};
let _faceEmbeddingSession: Promise<ort.InferenceSession> | undefined;
const faceEmbeddingSession = async () => {
if (!_faceEmbeddingSession) {
_faceEmbeddingSession =
faceEmbeddingModelPathDownloadingIfNeeded().then((modelPath) =>
createInferenceSession(modelPath),
);
}
return _faceEmbeddingSession;
};
export const detectFaces = async (input: Float32Array) => {
const session = await faceDetectionSession();
const t = Date.now();
const feeds = {
input: new ort.Tensor("float32", input, [1, 3, 640, 640]),
};
const results = await session.run(feeds);
log.debug(() => `onnx/yolo face detection took ${Date.now() - t} ms`);
return results["output"].data;
};
export const faceEmbedding = async (input: Float32Array) => {
// Dimension of each face (alias)
const mobileFaceNetFaceSize = 112;
// Smaller alias
const z = mobileFaceNetFaceSize;
// Size of each face's data in the batch
const n = Math.round(input.length / (z * z * 3));
const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]);
const session = await faceEmbeddingSession();
const t = Date.now();
const feeds = { img_inputs: inputTensor };
const results = await session.run(feeds);
log.debug(() => `onnx/yolo face embedding took ${Date.now() - t} ms`);
// TODO: What's with this type? It works in practice, but double check.
return (results.embeddings as unknown as any)["cpuData"]; // as Float32Array;
};

View file

@ -0,0 +1,79 @@
/**
* @file AI/ML related functionality.
*
* @see also `ml-clip.ts`, `ml-face.ts`.
*
* The ML runtime we use for inference is [ONNX](https://onnxruntime.ai). Models
* for various tasks are not shipped with the app but are downloaded on demand.
*
* The primary reason for doing these tasks in the Node.js layer is so that we
* can use the binary ONNX runtime which is 10-20x faster than the WASM based
* web one.
*/
import { app, net } from "electron/main";
import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "node:path";
import * as ort from "onnxruntime-node";
import { writeStream } from "../fs";
import log from "../log";
/**
* Download the model named {@link modelName} if we don't already have it.
*
* Also verify that the size of the model we get matches {@expectedByteSize} (if
* not, redownload it).
*
* @returns the path to the model on the local machine.
*/
export const modelPathDownloadingIfNeeded = async (
modelName: string,
expectedByteSize: number,
) => {
const modelPath = modelSavePath(modelName);
if (!existsSync(modelPath)) {
log.info("CLIP image model not found, downloading");
await downloadModel(modelPath, modelName);
} else {
const size = (await fs.stat(modelPath)).size;
if (size !== expectedByteSize) {
log.error(
`The size ${size} of model ${modelName} does not match the expected size, downloading again`,
);
await downloadModel(modelPath, modelName);
}
}
return modelPath;
};
/** Return the path where the given {@link modelName} is meant to be saved */
export const modelSavePath = (modelName: string) =>
path.join(app.getPath("userData"), "models", modelName);
export const downloadModel = async (saveLocation: string, name: string) => {
// `mkdir -p` the directory where we want to save the model.
const saveDir = path.dirname(saveLocation);
await fs.mkdir(saveDir, { recursive: true });
// Download
log.info(`Downloading ML model from ${name}`);
const url = `https://models.ente.io/${name}`;
const res = await net.fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
// Save
await writeStream(saveLocation, res.body);
log.info(`Downloaded CLIP model ${name}`);
};
/**
* Crete an ONNX {@link InferenceSession} with some defaults.
*/
export const createInferenceSession = async (modelPath: string) => {
return await ort.InferenceSession.create(modelPath, {
// Restrict the number of threads to 1
intraOpNumThreads: 1,
// Be more conservative with RAM usage
enableCpuMemArena: false,
});
};

View file

@ -0,0 +1,25 @@
import { safeStorage } from "electron/main";
import { keysStore } from "../stores/keys.store";
import { safeStorageStore } from "../stores/safeStorage.store";
import { uploadStatusStore } from "../stores/upload.store";
import { watchStore } from "../stores/watch.store";
export const clearStores = () => {
uploadStatusStore.clear();
keysStore.clear();
safeStorageStore.clear();
watchStore.clear();
};
export const saveEncryptionKey = async (encryptionKey: string) => {
const encryptedKey: Buffer = await safeStorage.encryptString(encryptionKey);
const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
safeStorageStore.set("encryptionKey", b64EncryptedKey);
};
export const encryptionKey = async (): Promise<string | undefined> => {
const b64EncryptedKey = safeStorageStore.get("encryptionKey");
if (!b64EncryptedKey) return undefined;
const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
return await safeStorage.decryptString(keyBuffer);
};

View file

@ -1,9 +1,37 @@
import StreamZip from "node-stream-zip"; import StreamZip from "node-stream-zip";
import path from "path"; import path from "path";
import { ElectronFile, FILE_PATH_TYPE } from "../../types/ipc";
import { FILE_PATH_KEYS } from "../../types/main";
import { uploadStatusStore } from "../stores/upload.store"; import { uploadStatusStore } from "../stores/upload.store";
import { ElectronFile, FILE_PATH_TYPE } from "../types/ipc"; import { getElectronFile, getValidPaths, getZipFileStream } from "./fs";
import { FILE_PATH_KEYS } from "../types/main";
import { getValidPaths, getZipFileStream } from "./fs"; export const getPendingUploads = async () => {
const filePaths = getSavedFilePaths(FILE_PATH_TYPE.FILES);
const zipPaths = getSavedFilePaths(FILE_PATH_TYPE.ZIPS);
const collectionName = uploadStatusStore.get("collectionName");
let files: ElectronFile[] = [];
let type: FILE_PATH_TYPE;
if (zipPaths.length) {
type = FILE_PATH_TYPE.ZIPS;
for (const zipPath of zipPaths) {
files = [
...files,
...(await getElectronFilesFromGoogleZip(zipPath)),
];
}
const pendingFilePaths = new Set(filePaths);
files = files.filter((file) => pendingFilePaths.has(file.path));
} else if (filePaths.length) {
type = FILE_PATH_TYPE.FILES;
files = await Promise.all(filePaths.map(getElectronFile));
}
return {
files,
collectionName,
type,
};
};
export const getSavedFilePaths = (type: FILE_PATH_TYPE) => { export const getSavedFilePaths = (type: FILE_PATH_TYPE) => {
const paths = const paths =

View file

@ -1,8 +1,7 @@
import type { FSWatcher } from "chokidar"; import type { FSWatcher } from "chokidar";
import ElectronLog from "electron-log"; import ElectronLog from "electron-log";
import { WatchMapping, WatchStoreType } from "../../types/ipc";
import { watchStore } from "../stores/watch.store"; import { watchStore } from "../stores/watch.store";
import { WatchMapping, WatchStoreType } from "../types/ipc";
import { isMappingPresent } from "../utils/watch";
export const addWatchMapping = async ( export const addWatchMapping = async (
watcher: FSWatcher, watcher: FSWatcher,
@ -29,6 +28,13 @@ export const addWatchMapping = async (
setWatchMappings(watchMappings); setWatchMappings(watchMappings);
}; };
function isMappingPresent(watchMappings: WatchMapping[], folderPath: string) {
const watchMapping = watchMappings?.find(
(mapping) => mapping.folderPath === folderPath,
);
return !!watchMapping;
}
export const removeWatchMapping = async ( export const removeWatchMapping = async (
watcher: FSWatcher, watcher: FSWatcher,
folderPath: string, folderPath: string,

View file

@ -1,5 +1,5 @@
import Store, { Schema } from "electron-store"; import Store, { Schema } from "electron-store";
import type { KeysStoreType } from "../types/main"; import type { KeysStoreType } from "../../types/main";
const keysStoreSchema: Schema<KeysStoreType> = { const keysStoreSchema: Schema<KeysStoreType> = {
AnonymizeUserID: { AnonymizeUserID: {

View file

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

View file

@ -1,5 +1,5 @@
import Store, { Schema } from "electron-store"; import Store, { Schema } from "electron-store";
import type { UploadStoreType } from "../types/main"; import type { UploadStoreType } from "../../types/main";
const uploadStoreSchema: Schema<UploadStoreType> = { const uploadStoreSchema: Schema<UploadStoreType> = {
filePaths: { filePaths: {

View file

@ -1,7 +1,12 @@
import Store, { Schema } from "electron-store"; import Store, { Schema } from "electron-store";
import type { UserPreferencesType } from "../types/main";
const userPreferencesSchema: Schema<UserPreferencesType> = { interface UserPreferencesSchema {
hideDockIcon: boolean;
skipAppVersion?: string;
muteUpdateNotificationVersion?: string;
}
const userPreferencesSchema: Schema<UserPreferencesSchema> = {
hideDockIcon: { hideDockIcon: {
type: "boolean", type: "boolean",
}, },
@ -13,7 +18,7 @@ const userPreferencesSchema: Schema<UserPreferencesType> = {
}, },
}; };
export const userPreferencesStore = new Store({ export const userPreferences = new Store({
name: "userPreferences", name: "userPreferences",
schema: userPreferencesSchema, schema: userPreferencesSchema,
}); });

View file

@ -1,5 +1,5 @@
import Store, { Schema } from "electron-store"; import Store, { Schema } from "electron-store";
import { WatchStoreType } from "../types/ipc"; import { WatchStoreType } from "../../types/ipc";
const watchStoreSchema: Schema<WatchStoreType> = { const watchStoreSchema: Schema<WatchStoreType> = {
mappings: { mappings: {

View file

@ -19,6 +19,7 @@
* curl -v -H "Location;" -H "User-Agent: FooBar's so-called ""Browser""" "http://www.daveeddy.com/?name=dave&age=24" * curl -v -H "Location;" -H "User-Agent: FooBar's so-called ""Browser""" "http://www.daveeddy.com/?name=dave&age=24"
Which is suitable for being executed by the shell. Which is suitable for being executed by the shell.
*/ */
/* eslint-disable no-unused-vars */
declare module "any-shell-escape" { declare module "any-shell-escape" {
declare const shellescape: (args: readonly string | string[]) => string; declare const shellescape: (args: readonly string | string[]) => string;
export default shellescape; export default shellescape;

View file

@ -0,0 +1,9 @@
/**
* Types for [onnxruntime-node](https://onnxruntime.ai/docs/api/js/index.html).
*
* Note: these are not the official types but are based on a temporary
* [workaround](https://github.com/microsoft/onnxruntime/issues/17979).
*/
declare module "onnxruntime-node" {
export * from "onnxruntime-common";
}

View file

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
/** /**
* @file The preload script * @file The preload script
* *
@ -31,7 +32,7 @@
* and when changing one of them, remember to see if the other two also need * and when changing one of them, remember to see if the other two also need
* changing: * changing:
* *
* - [renderer] web/packages/shared/electron/types.ts contains docs * - [renderer] web/packages/next/types/electron.ts contains docs
* - [preload] desktop/src/preload.ts * - [preload] desktop/src/preload.ts
* - [main] desktop/src/main/ipc.ts contains impl * - [main] desktop/src/main/ipc.ts contains impl
*/ */
@ -44,7 +45,6 @@ import type {
AppUpdateInfo, AppUpdateInfo,
ElectronFile, ElectronFile,
FILE_PATH_TYPE, FILE_PATH_TYPE,
Model,
WatchMapping, WatchMapping,
} from "./types/ipc"; } from "./types/ipc";
@ -52,60 +52,55 @@ import type {
const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion"); const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion");
const logToDisk = (message: string): void =>
ipcRenderer.send("logToDisk", message);
const openDirectory = (dirPath: string): Promise<void> => const openDirectory = (dirPath: string): Promise<void> =>
ipcRenderer.invoke("openDirectory"); ipcRenderer.invoke("openDirectory", dirPath);
const openLogDirectory = (): Promise<void> => const openLogDirectory = (): Promise<void> =>
ipcRenderer.invoke("openLogDirectory"); ipcRenderer.invoke("openLogDirectory");
const logToDisk = (message: string): void => const clearStores = () => ipcRenderer.send("clearStores");
ipcRenderer.send("logToDisk", message);
const encryptionKey = (): Promise<string | undefined> =>
ipcRenderer.invoke("encryptionKey");
const saveEncryptionKey = (encryptionKey: string): Promise<void> =>
ipcRenderer.invoke("saveEncryptionKey", encryptionKey);
const onMainWindowFocus = (cb?: () => void) => {
ipcRenderer.removeAllListeners("mainWindowFocus");
if (cb) ipcRenderer.on("mainWindowFocus", cb);
};
// - App update
const onAppUpdateAvailable = (
cb?: ((updateInfo: AppUpdateInfo) => void) | undefined,
) => {
ipcRenderer.removeAllListeners("appUpdateAvailable");
if (cb) {
ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) =>
cb(updateInfo),
);
}
};
const updateAndRestart = () => ipcRenderer.send("updateAndRestart");
const updateOnNextRestart = (version: string) =>
ipcRenderer.send("updateOnNextRestart", version);
const skipAppUpdate = (version: string) => {
ipcRenderer.send("skipAppUpdate", version);
};
const fsExists = (path: string): Promise<boolean> => const fsExists = (path: string): Promise<boolean> =>
ipcRenderer.invoke("fsExists", path); ipcRenderer.invoke("fsExists", path);
// - AUDIT below this // - AUDIT below this
const registerForegroundEventListener = (onForeground: () => void) => {
ipcRenderer.removeAllListeners("app-in-foreground");
ipcRenderer.on("app-in-foreground", () => {
onForeground();
});
};
const clearElectronStore = () => {
ipcRenderer.send("clear-electron-store");
};
const setEncryptionKey = (encryptionKey: string): Promise<void> =>
ipcRenderer.invoke("setEncryptionKey", encryptionKey);
const getEncryptionKey = (): Promise<string> =>
ipcRenderer.invoke("getEncryptionKey");
// - App update
const registerUpdateEventListener = (
showUpdateDialog: (updateInfo: AppUpdateInfo) => void,
) => {
ipcRenderer.removeAllListeners("show-update-dialog");
ipcRenderer.on("show-update-dialog", (_, updateInfo: AppUpdateInfo) => {
showUpdateDialog(updateInfo);
});
};
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);
};
// - Conversion // - Conversion
const convertToJPEG = ( const convertToJPEG = (
@ -142,17 +137,17 @@ const runFFmpegCmd = (
// - ML // - ML
const computeImageEmbedding = ( const clipImageEmbedding = (jpegImageData: Uint8Array): Promise<Float32Array> =>
model: Model, ipcRenderer.invoke("clipImageEmbedding", jpegImageData);
imageData: Uint8Array,
): Promise<Float32Array> =>
ipcRenderer.invoke("computeImageEmbedding", model, imageData);
const computeTextEmbedding = ( const clipTextEmbedding = (text: string): Promise<Float32Array> =>
model: Model, ipcRenderer.invoke("clipTextEmbedding", text);
text: string,
): Promise<Float32Array> => const detectFaces = (input: Float32Array): Promise<Float32Array> =>
ipcRenderer.invoke("computeTextEmbedding", model, text); ipcRenderer.invoke("detectFaces", input);
const faceEmbedding = (input: Float32Array): Promise<Float32Array> =>
ipcRenderer.invoke("faceEmbedding", input);
// - File selection // - File selection
@ -228,11 +223,11 @@ const checkExistsAndCreateDir = (dirPath: string): Promise<void> =>
const saveStreamToDisk = ( const saveStreamToDisk = (
path: string, path: string,
fileStream: ReadableStream<any>, fileStream: ReadableStream,
): Promise<void> => ipcRenderer.invoke("saveStreamToDisk", path, fileStream); ): Promise<void> => ipcRenderer.invoke("saveStreamToDisk", path, fileStream);
const saveFileToDisk = (path: string, file: any): Promise<void> => const saveFileToDisk = (path: string, contents: string): Promise<void> =>
ipcRenderer.invoke("saveFileToDisk", path, file); ipcRenderer.invoke("saveFileToDisk", path, contents);
const readTextFile = (path: string): Promise<string> => const readTextFile = (path: string): Promise<string> =>
ipcRenderer.invoke("readTextFile", path); ipcRenderer.invoke("readTextFile", path);
@ -308,24 +303,22 @@ const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
// //
// The copy itself is relatively fast, but the problem with transfering large // The copy itself is relatively fast, but the problem with transfering large
// amounts of data is potentially running out of memory during the copy. // amounts of data is potentially running out of memory during the copy.
contextBridge.exposeInMainWorld("ElectronAPIs", { contextBridge.exposeInMainWorld("electron", {
// - General // - General
appVersion, appVersion,
openDirectory,
registerForegroundEventListener,
clearElectronStore,
getEncryptionKey,
setEncryptionKey,
// - Logging
openLogDirectory,
logToDisk, logToDisk,
openDirectory,
openLogDirectory,
clearStores,
encryptionKey,
saveEncryptionKey,
onMainWindowFocus,
// - App update // - App update
onAppUpdateAvailable,
updateAndRestart, updateAndRestart,
updateOnNextRestart,
skipAppUpdate, skipAppUpdate,
muteUpdateNotification,
registerUpdateEventListener,
// - Conversion // - Conversion
convertToJPEG, convertToJPEG,
@ -333,8 +326,10 @@ contextBridge.exposeInMainWorld("ElectronAPIs", {
runFFmpegCmd, runFFmpegCmd,
// - ML // - ML
computeImageEmbedding, clipImageEmbedding,
computeTextEmbedding, clipTextEmbedding,
detectFaces,
faceEmbedding,
// - File selection // - File selection
selectDirectory, selectDirectory,

View file

@ -1,133 +0,0 @@
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 { setIsAppQuitting, setIsUpdateAvailable } from "../main";
import { logErrorSentry } from "../main/log";
import { AppUpdateInfo } from "../types/ipc";
import {
clearMuteUpdateNotificationVersion,
clearSkipAppVersion,
getMuteUpdateNotificationVersion,
getSkipAppVersion,
setMuteUpdateNotificationVersion,
setSkipAppVersion,
} from "./userPreference";
const FIVE_MIN_IN_MICROSECOND = 5 * 60 * 1000;
const ONE_DAY_IN_MICROSECOND = 1 * 24 * 60 * 60 * 1000;
export function setupAutoUpdater(mainWindow: BrowserWindow) {
autoUpdater.logger = log;
autoUpdater.autoDownload = false;
checkForUpdateAndNotify(mainWindow);
setInterval(
() => checkForUpdateAndNotify(mainWindow),
ONE_DAY_IN_MICROSECOND,
);
}
export function forceCheckForUpdateAndNotify(mainWindow: BrowserWindow) {
try {
clearSkipAppVersion();
clearMuteUpdateNotificationVersion();
checkForUpdateAndNotify(mainWindow);
} catch (e) {
logErrorSentry(e, "forceCheckForUpdateAndNotify failed");
}
}
async function checkForUpdateAndNotify(mainWindow: BrowserWindow) {
try {
log.debug("checkForUpdateAndNotify called");
const updateCheckResult = await autoUpdater.checkForUpdates();
log.debug("update version", updateCheckResult.updateInfo.version);
if (
compareVersions(
updateCheckResult.updateInfo.version,
app.getVersion(),
) <= 0
) {
log.debug("already at latest version");
return;
}
const skipAppVersion = getSkipAppVersion();
if (
skipAppVersion &&
updateCheckResult.updateInfo.version === skipAppVersion
) {
log.info(
"user chose to skip version ",
updateCheckResult.updateInfo.version,
);
return;
}
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");
}
}
export function updateAndRestart() {
ElectronLog.log("user quit the app");
setIsAppQuitting(true);
autoUpdater.quitAndInstall();
}
/**
* 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);
}
export function muteUpdateNotification(version: string) {
setMuteUpdateNotificationVersion(version);
}
function showUpdateDialog(
mainWindow: BrowserWindow,
updateInfo: AppUpdateInfo,
) {
mainWindow.webContents.send("show-update-dialog", updateInfo);
}

View file

@ -1,506 +0,0 @@
import { app, net } from "electron/main";
import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "node:path";
import { CustomErrors } from "../constants/errors";
import { writeStream } from "../main/fs";
import log, { logErrorSentry } from "../main/log";
import { execAsync, isDev } from "../main/util";
import { Model } from "../types/ipc";
import Tokenizer from "../utils/clip-bpe-ts/mod";
import { getPlatform } from "../utils/common/platform";
import { generateTempFilePath } from "../utils/temp";
import { deleteTempFile } from "./ffmpeg";
const jpeg = require("jpeg-js");
const CLIP_MODEL_PATH_PLACEHOLDER = "CLIP_MODEL";
const GGMLCLIP_PATH_PLACEHOLDER = "GGML_PATH";
const INPUT_PATH_PLACEHOLDER = "INPUT";
const IMAGE_EMBEDDING_EXTRACT_CMD: string[] = [
GGMLCLIP_PATH_PLACEHOLDER,
"-mv",
CLIP_MODEL_PATH_PLACEHOLDER,
"--image",
INPUT_PATH_PLACEHOLDER,
];
const TEXT_EMBEDDING_EXTRACT_CMD: string[] = [
GGMLCLIP_PATH_PLACEHOLDER,
"-mt",
CLIP_MODEL_PATH_PLACEHOLDER,
"--text",
INPUT_PATH_PLACEHOLDER,
];
const ort = require("onnxruntime-node");
const TEXT_MODEL_DOWNLOAD_URL = {
ggml: "https://models.ente.io/clip-vit-base-patch32_ggml-text-model-f16.gguf",
onnx: "https://models.ente.io/clip-text-vit-32-uint8.onnx",
};
const IMAGE_MODEL_DOWNLOAD_URL = {
ggml: "https://models.ente.io/clip-vit-base-patch32_ggml-vision-model-f16.gguf",
onnx: "https://models.ente.io/clip-image-vit-32-float32.onnx",
};
const TEXT_MODEL_NAME = {
ggml: "clip-vit-base-patch32_ggml-text-model-f16.gguf",
onnx: "clip-text-vit-32-uint8.onnx",
};
const IMAGE_MODEL_NAME = {
ggml: "clip-vit-base-patch32_ggml-vision-model-f16.gguf",
onnx: "clip-image-vit-32-float32.onnx",
};
const IMAGE_MODEL_SIZE_IN_BYTES = {
ggml: 175957504, // 167.8 MB
onnx: 351468764, // 335.2 MB
};
const TEXT_MODEL_SIZE_IN_BYTES = {
ggml: 127853440, // 121.9 MB,
onnx: 64173509, // 61.2 MB
};
/** 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);
await fs.mkdir(saveDir, { recursive: true });
log.info("downloading clip model");
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");
}
let imageModelDownloadInProgress: Promise<void> = null;
export async function getClipImageModelPath(type: "ggml" | "onnx") {
try {
const modelSavePath = getModelSavePath(IMAGE_MODEL_NAME[type]);
if (imageModelDownloadInProgress) {
log.info("waiting for image model download to finish");
await imageModelDownloadInProgress;
} else {
if (!existsSync(modelSavePath)) {
log.info("clip image model not found, downloading");
imageModelDownloadInProgress = downloadModel(
modelSavePath,
IMAGE_MODEL_DOWNLOAD_URL[type],
);
await imageModelDownloadInProgress;
} else {
const localFileSize = (await fs.stat(modelSavePath)).size;
if (localFileSize !== IMAGE_MODEL_SIZE_IN_BYTES[type]) {
log.info(
`clip image model size mismatch, downloading again got: ${localFileSize}`,
);
imageModelDownloadInProgress = downloadModel(
modelSavePath,
IMAGE_MODEL_DOWNLOAD_URL[type],
);
await imageModelDownloadInProgress;
}
}
}
return modelSavePath;
} finally {
imageModelDownloadInProgress = null;
}
}
let textModelDownloadInProgress: boolean = false;
export async function getClipTextModelPath(type: "ggml" | "onnx") {
const modelSavePath = getModelSavePath(TEXT_MODEL_NAME[type]);
if (textModelDownloadInProgress) {
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
if (!existsSync(modelSavePath)) {
log.info("clip text model not found, downloading");
textModelDownloadInProgress = true;
downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type])
.catch(() => {
// ignore
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
const localFileSize = (await fs.stat(modelSavePath)).size;
if (localFileSize !== TEXT_MODEL_SIZE_IN_BYTES[type]) {
log.info(
`clip text model size mismatch, downloading again got: ${localFileSize}`,
);
textModelDownloadInProgress = true;
downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type])
.catch(() => {
// ignore
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
}
}
}
return modelSavePath;
}
function getGGMLClipPath() {
return isDev
? path.join("./build", `ggmlclip-${getPlatform()}`)
: path.join(process.resourcesPath, `ggmlclip-${getPlatform()}`);
}
async function createOnnxSession(modelPath: string) {
return await ort.InferenceSession.create(modelPath, {
intraOpNumThreads: 1,
enableCpuMemArena: false,
});
}
let onnxImageSessionPromise: Promise<any> = null;
async function getOnnxImageSession() {
if (!onnxImageSessionPromise) {
onnxImageSessionPromise = (async () => {
const clipModelPath = await getClipImageModelPath("onnx");
return createOnnxSession(clipModelPath);
})();
}
return onnxImageSessionPromise;
}
let onnxTextSession: any = null;
async function getOnnxTextSession() {
if (!onnxTextSession) {
const clipModelPath = await getClipTextModelPath("onnx");
onnxTextSession = await createOnnxSession(clipModelPath);
}
return onnxTextSession;
}
let tokenizer: Tokenizer = null;
function getTokenizer() {
if (!tokenizer) {
tokenizer = new Tokenizer();
}
return tokenizer;
}
export const computeImageEmbedding = async (
model: Model,
imageData: Uint8Array,
): Promise<Float32Array> => {
let tempInputFilePath = null;
try {
tempInputFilePath = await generateTempFilePath("");
const imageStream = new Response(imageData.buffer).body;
await writeStream(tempInputFilePath, imageStream);
const embedding = await computeImageEmbedding_(
model,
tempInputFilePath,
);
return embedding;
} catch (err) {
if (isExecError(err)) {
const parsedExecError = parseExecError(err);
throw Error(parsedExecError);
} else {
throw err;
}
} finally {
if (tempInputFilePath) {
await deleteTempFile(tempInputFilePath);
}
}
};
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 CustomErrors.UNSUPPORTED_PLATFORM(
process.platform,
process.arch,
);
} else {
return errMessage;
}
};
async function computeImageEmbedding_(
model: Model,
inputFilePath: string,
): Promise<Float32Array> {
if (!existsSync(inputFilePath)) {
throw Error(CustomErrors.INVALID_FILE_PATH);
}
if (model === Model.GGML_CLIP) {
return await computeGGMLImageEmbedding(inputFilePath);
} else if (model === Model.ONNX_CLIP) {
return await computeONNXImageEmbedding(inputFilePath);
} else {
throw Error(CustomErrors.INVALID_CLIP_MODEL(model));
}
}
export async function computeGGMLImageEmbedding(
inputFilePath: string,
): Promise<Float32Array> {
try {
const clipModelPath = await getClipImageModelPath("ggml");
const ggmlclipPath = getGGMLClipPath();
const cmd = IMAGE_EMBEDDING_EXTRACT_CMD.map((cmdPart) => {
if (cmdPart === GGMLCLIP_PATH_PLACEHOLDER) {
return ggmlclipPath;
} else if (cmdPart === CLIP_MODEL_PATH_PLACEHOLDER) {
return clipModelPath;
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return inputFilePath;
} else {
return cmdPart;
}
});
const { stdout } = await execAsync(cmd);
// parse stdout and return embedding
// get the last line of stdout
const lines = stdout.split("\n");
const lastLine = lines[lines.length - 1];
const embedding = JSON.parse(lastLine);
const embeddingArray = new Float32Array(embedding);
return embeddingArray;
} catch (err) {
log.error("Failed to compute GGML image embedding", err);
throw err;
}
}
export async function computeONNXImageEmbedding(
inputFilePath: string,
): Promise<Float32Array> {
try {
const imageSession = await getOnnxImageSession();
const t1 = Date.now();
const rgbData = await getRGBData(inputFilePath);
const feeds = {
input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
log.info(
`onnx image embedding time: ${Date.now() - t1} ms (prep:${
t2 - t1
} ms, extraction: ${Date.now() - t2} ms)`,
);
const imageEmbedding = results["output"].data; // Float32Array
return normalizeEmbedding(imageEmbedding);
} catch (err) {
log.error("Failed to compute ONNX image embedding", err);
throw err;
}
}
export async function computeTextEmbedding(
model: Model,
text: string,
): Promise<Float32Array> {
try {
const embedding = computeTextEmbedding_(model, text);
return embedding;
} catch (err) {
if (isExecError(err)) {
const parsedExecError = parseExecError(err);
throw Error(parsedExecError);
} else {
throw err;
}
}
}
async function computeTextEmbedding_(
model: Model,
text: string,
): Promise<Float32Array> {
if (model === Model.GGML_CLIP) {
return await computeGGMLTextEmbedding(text);
} else {
return await computeONNXTextEmbedding(text);
}
}
export async function computeGGMLTextEmbedding(
text: string,
): Promise<Float32Array> {
try {
const clipModelPath = await getClipTextModelPath("ggml");
const ggmlclipPath = getGGMLClipPath();
const cmd = TEXT_EMBEDDING_EXTRACT_CMD.map((cmdPart) => {
if (cmdPart === GGMLCLIP_PATH_PLACEHOLDER) {
return ggmlclipPath;
} else if (cmdPart === CLIP_MODEL_PATH_PLACEHOLDER) {
return clipModelPath;
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return text;
} else {
return cmdPart;
}
});
const { stdout } = await execAsync(cmd);
// parse stdout and return embedding
// get the last line of stdout
const lines = stdout.split("\n");
const lastLine = lines[lines.length - 1];
const embedding = JSON.parse(lastLine);
const embeddingArray = new Float32Array(embedding);
return embeddingArray;
} catch (err) {
if (err.message === CustomErrors.MODEL_DOWNLOAD_PENDING) {
log.info(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
log.error("Failed to compute GGML text embedding", err);
}
throw err;
}
}
export async function computeONNXTextEmbedding(
text: string,
): Promise<Float32Array> {
try {
const imageSession = await getOnnxTextSession();
const t1 = Date.now();
const tokenizer = getTokenizer();
const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
const feeds = {
input: new ort.Tensor("int32", tokenizedText, [1, 77]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
log.info(
`onnx text embedding time: ${Date.now() - t1} ms (prep:${
t2 - t1
} ms, extraction: ${Date.now() - t2} ms)`,
);
const textEmbedding = results["output"].data; // Float32Array
return normalizeEmbedding(textEmbedding);
} catch (err) {
if (err.message === CustomErrors.MODEL_DOWNLOAD_PENDING) {
log.info(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
logErrorSentry(err, "Error in computeONNXTextEmbedding");
}
throw err;
}
}
async function getRGBData(inputFilePath: string) {
const jpegData = await fs.readFile(inputFilePath);
let rawImageData;
try {
rawImageData = jpeg.decode(jpegData, {
useTArray: true,
formatAsRGBA: false,
});
} catch (err) {
logErrorSentry(err, "JPEG decode error");
throw err;
}
const nx: number = rawImageData.width;
const ny: number = rawImageData.height;
const inputImage: Uint8Array = rawImageData.data;
const nx2: number = 224;
const ny2: number = 224;
const totalSize: number = 3 * nx2 * ny2;
const result: number[] = Array(totalSize).fill(0);
const scale: number = Math.max(nx, ny) / 224;
const nx3: number = Math.round(nx / scale);
const ny3: number = Math.round(ny / scale);
const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
const std: number[] = [0.26862954, 0.26130258, 0.27577711];
for (let y = 0; y < ny3; y++) {
for (let x = 0; x < nx3; x++) {
for (let c = 0; c < 3; c++) {
// linear interpolation
const sx: number = (x + 0.5) * scale - 0.5;
const sy: number = (y + 0.5) * scale - 0.5;
const x0: number = Math.max(0, Math.floor(sx));
const y0: number = Math.max(0, Math.floor(sy));
const x1: number = Math.min(x0 + 1, nx - 1);
const y1: number = Math.min(y0 + 1, ny - 1);
const dx: number = sx - x0;
const dy: number = sy - y0;
const j00: number = 3 * (y0 * nx + x0) + c;
const j01: number = 3 * (y0 * nx + x1) + c;
const j10: number = 3 * (y1 * nx + x0) + c;
const j11: number = 3 * (y1 * nx + x1) + c;
const v00: number = inputImage[j00];
const v01: number = inputImage[j01];
const v10: number = inputImage[j10];
const v11: number = inputImage[j11];
const v0: number = v00 * (1 - dx) + v01 * dx;
const v1: number = v10 * (1 - dx) + v11 * dx;
const v: number = v0 * (1 - dy) + v1 * dy;
const v2: number = Math.min(Math.max(Math.round(v), 0), 255);
// createTensorWithDataList is dump compared to reshape and hence has to be given with one channel after another
const i: number = y * nx3 + x + (c % 3) * 224 * 224;
result[i] = (v2 / 255 - mean[c]) / std[c];
}
}
}
return result;
}
export const computeClipMatchScore = async (
imageEmbedding: Float32Array,
textEmbedding: Float32Array,
) => {
if (imageEmbedding.length !== textEmbedding.length) {
throw Error("imageEmbedding and textEmbedding length mismatch");
}
let score = 0;
for (let index = 0; index < imageEmbedding.length; index++) {
score += imageEmbedding[index] * textEmbedding[index];
}
return score;
};
export const normalizeEmbedding = (embedding: Float32Array) => {
let normalization = 0;
for (let index = 0; index < embedding.length; index++) {
normalization += embedding[index] * embedding[index];
}
const sqrtNormalization = Math.sqrt(normalization);
for (let index = 0; index < embedding.length; index++) {
embedding[index] = embedding[index] / sqrtNormalization;
}
return embedding;
};

View file

@ -1,33 +0,0 @@
import { userPreferencesStore } from "../stores/userPreferences.store";
export function getHideDockIconPreference() {
return userPreferencesStore.get("hideDockIcon");
}
export function setHideDockIconPreference(shouldHideDockIcon: boolean) {
userPreferencesStore.set("hideDockIcon", shouldHideDockIcon);
}
export function getSkipAppVersion() {
return userPreferencesStore.get("skipAppVersion");
}
export function setSkipAppVersion(version: string) {
userPreferencesStore.set("skipAppVersion", version);
}
export function getMuteUpdateNotificationVersion() {
return userPreferencesStore.get("muteUpdateNotificationVersion");
}
export function setMuteUpdateNotificationVersion(version: string) {
userPreferencesStore.set("muteUpdateNotificationVersion", version);
}
export function clearSkipAppVersion() {
userPreferencesStore.delete("skipAppVersion");
}
export function clearMuteUpdateNotificationVersion() {
userPreferencesStore.delete("muteUpdateNotificationVersion");
}

View file

@ -4,6 +4,32 @@
* This file is manually kept in sync with the renderer code. * This file is manually kept in sync with the renderer code.
* See [Note: types.ts <-> preload.ts <-> ipc.ts] * See [Note: types.ts <-> preload.ts <-> ipc.ts]
*/ */
/**
* Errors that have special semantics on the web side.
*
* [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
*/
export const CustomErrors = {
WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED:
"Windows native image processing is not supported",
UNSUPPORTED_PLATFORM: (platform: string, arch: string) =>
`Unsupported platform - ${platform} ${arch}`,
MODEL_DOWNLOAD_PENDING:
"Model download pending, skipping clip search request",
};
/** /**
* Deprecated - Use File + webUtils.getPathForFile instead * Deprecated - Use File + webUtils.getPathForFile instead
* *
@ -45,6 +71,7 @@ export interface WatchStoreType {
} }
export enum FILE_PATH_TYPE { export enum FILE_PATH_TYPE {
/* eslint-disable no-unused-vars */
FILES = "files", FILES = "files",
ZIPS = "zips", ZIPS = "zips",
} }
@ -53,8 +80,3 @@ export interface AppUpdateInfo {
autoUpdatable: boolean; autoUpdatable: boolean;
version: string; version: string;
} }
export enum Model {
GGML_CLIP = "ggml-clip",
ONNX_CLIP = "onnx-clip",
}

View file

@ -18,6 +18,7 @@ export interface KeysStoreType {
}; };
} }
/* eslint-disable no-unused-vars */
export const FILE_PATH_KEYS: { export const FILE_PATH_KEYS: {
[k in FILE_PATH_TYPE]: keyof UploadStoreType; [k in FILE_PATH_TYPE]: keyof UploadStoreType;
} = { } = {
@ -28,9 +29,3 @@ export const FILE_PATH_KEYS: {
export interface SafeStorageStoreType { export interface SafeStorageStoreType {
encryptionKey: string; encryptionKey: string;
} }
export interface UserPreferencesType {
hideDockIcon: boolean;
skipAppVersion: string;
muteUpdateNotificationVersion: string;
}

View file

@ -1,11 +0,0 @@
import { WatchMapping } from "../types/ipc";
export function isMappingPresent(
watchMappings: WatchMapping[],
folderPath: string,
) {
const watchMapping = watchMappings?.find(
(mapping) => mapping.folderPath === folderPath,
);
return !!watchMapping;
}

View file

@ -139,7 +139,17 @@ export const sidebar = [
text: "Auth", text: "Auth",
items: [ items: [
{ text: "Introduction", link: "/auth/" }, { text: "Introduction", link: "/auth/" },
{ text: "FAQ", link: "/auth/faq/" }, {
text: "FAQ",
collapsed: true,
items: [
{ text: "General", link: "/auth/faq/" },
{
text: "Enteception",
link: "/auth/faq/enteception/",
},
],
},
{ {
text: "Migration", text: "Migration",
collapsed: true, collapsed: true,
@ -170,6 +180,10 @@ export const sidebar = [
text: "Connect to custom server", text: "Connect to custom server",
link: "/self-hosting/guides/custom-server/", link: "/self-hosting/guides/custom-server/",
}, },
{
text: "Hosting the web app",
link: "/self-hosting/guides/web-app",
},
{ {
text: "Administering your server", text: "Administering your server",
link: "/self-hosting/guides/admin", link: "/self-hosting/guides/admin",
@ -197,11 +211,19 @@ export const sidebar = [
text: "Verification code", text: "Verification code",
link: "/self-hosting/faq/otp", link: "/self-hosting/faq/otp",
}, },
{
text: "Shared albums",
link: "/self-hosting/faq/sharing",
},
], ],
}, },
{ {
text: "Troubleshooting", text: "Troubleshooting",
items: [ items: [
{
text: "Uploads",
link: "/self-hosting/troubleshooting/uploads",
},
{ {
text: "Yarn", text: "Yarn",
link: "/self-hosting/troubleshooting/yarn", link: "/self-hosting/troubleshooting/yarn",
@ -219,80 +241,3 @@ export const sidebar = [
link: "/about/contribute", link: "/about/contribute",
}, },
]; ];
function sidebarOld() {
return [
{
text: "Welcome",
items: [
{
text: "Features",
collapsed: true,
items: [
{
text: "Family Plan",
link: "/photos/features/family-plan",
},
{ text: "Albums", link: "/photos/features/albums" },
{ text: "Archive", link: "/photos/features/archive" },
{ text: "Hidden", link: "/photos/features/hidden" },
{ text: "Map", link: "/photos/features/map" },
{
text: "Location Tags",
link: "/photos/features/location",
},
{
text: "Collect Photos",
link: "/photos/features/collect",
},
{
text: "Public links",
link: "/photos/features/public-links",
},
{
text: "Quick link",
link: "/photos/features/quick-link",
},
{
text: "Watch folder",
link: "/photos/features/watch-folders",
},
{ text: "Trash", link: "/photos/features/trash" },
{
text: "Uncategorized",
link: "/photos/features/uncategorized",
},
{
text: "Referral Plan",
link: "/photos/features/referral",
},
{
text: "Live & Motion Photos",
link: "/photos/features/live-photos",
},
{ text: "Cast", link: "/photos/features/cast" },
],
},
{
text: "Troubleshoot",
collapsed: true,
link: "/photos/troubleshooting/files-not-uploading",
items: [
{
text: "Files not uploading",
link: "/photos/troubleshooting/files-not-uploading",
},
{
text: "Failed to play video",
link: "/photos/troubleshooting/video-not-playing",
},
{
text: "Report bug",
link: "/photos/troubleshooting/report-bug",
},
],
},
],
},
];
}

View file

@ -0,0 +1,51 @@
---
title: Enteception
description: Using Ente Auth to store 2FA for your Ente account
---
# Enteception
Your 2FA codes are in Ente Auth, but if you enable 2FA for your Ente account
itself, where should the 2FA for your Ente account be stored?
There are multiple answers, none of which are better or worse, they just depend
on your situation and risk tolerance.
If you are using the same account for both Ente Photos and Ente Auth and have
enabled 2FA from the ente Photos app, we recommend that you ensure you store
your recovery key in a safe place (writing it down on a paper is a good idea).
This key can be used to bypass Ente 2FA in case you are locked out.
Another option is to use a separate account for Ente Auth.
Also, taking exporting the encrypted backup is also another good way to reduce
the risk (you can easily import the encrypted backup without signing in).
Finally, we have on our roadmap some features like adding support for
emergency/legacy-contacts, passkeys, and hardware security keys. Beyond other
benefits, all of these would further reduce the risk of users getting locked out
of their accounts.
## Email verification for Ente Auth
There is a related ouroboros scenario where if email verification is enabled in
the Ente Auth app _and_ the 2FA for your email provider is stored in Ente Auth,
then you might need a code from your email to log into Ente Auth, but to log
into your email you needed the Auth code.
To prevent people from accidentally locking themselves out this way, email
verification is disabled by default in the auth app. We also try to show a
warning when you try to enable email verification in the auth app:
<div align="center">
![Warning shown when enabling 2FA in Ente Auth](warning.png){width=400px}
</div>
The solution here are the same as the Ente-in-Ente case.
## TL;DR;
Ideally, you should **note down your recovery key in a safe place (may be on a
paper)**, using which you will be able to by-pass the two factor.

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

View file

@ -31,3 +31,22 @@ You can enable FaceID lock under Settings → Security → Lockscreen.
### Why does the desktop and mobile app displays different code? ### Why does the desktop and mobile app displays different code?
Please verify that the time on both your mobile and desktop is same. Please verify that the time on both your mobile and desktop is same.
### Does ente Authenticator require an account?
Answer: No, ente Authenticator does not require an account. You can choose to
use the app without backups if you prefer.
### Can I use the Ente 2FA app on multiple devices and sync them?
Yes, you can download the Ente app on multiple devices and sync the codes,
end-to-end encrypted.
### What does it mean when I receive a message saying my current device is not powerful enough to verify my password?
This means that the parameters that were used to derive your master-key on your
original device, are incompatible with your current device (likely because it's
less powerful).
If you recover your account via your current device and reset the password, it
will re-generate a key that will be compatible on both devices.

View file

@ -12,14 +12,16 @@ in a local drive or NAS of your choice. This way, you can use Ente in your day
to day use, but will have an additional guarantee that a copy of your original to day use, but will have an additional guarantee that a copy of your original
photos and videos are always available in normal directories and files. photos and videos are always available in normal directories and files.
* You can use [Ente's CLI](https://github.com/ente-io/ente/tree/main/cli#export) - You can use
to export your data in a cron job to a location of your choice. The exports [Ente's CLI](https://github.com/ente-io/ente/tree/main/cli#export) to export
are incremental, and will also gracefully handle interruptions. your data in a cron job to a location of your choice. The exports are
incremental, and will also gracefully handle interruptions.
* Similarly, you can use Ente's [desktop app](https://ente.io/download/desktop) - Similarly, you can use Ente's
to export your data to a folder of your choice. The desktop app also supports [desktop app](https://ente.io/download/desktop) to export your data to a
"continuous" exports, where it will automatically export new items in the folder of your choice. The desktop app also supports "continuous" exports,
background without you needing to run any other cron jobs. See where it will automatically export new items in the background without you
needing to run any other cron jobs. See
[migration/export](/photos/migration/export/) for more details. [migration/export](/photos/migration/export/) for more details.
## Does the exported data from Ente photos preserve the same folder and album structure as in the app? ## Does the exported data from Ente photos preserve the same folder and album structure as in the app?

View file

@ -77,26 +77,45 @@ It's like cafe 😊. kaf-_ay_. en-_tay_.
## Does Ente apply compression to uploaded photos? ## Does Ente apply compression to uploaded photos?
Ente does not apply compression to uploaded photos. The file size of your photos in Ente will be similar to the original file sizes you have. Ente does not apply compression to uploaded photos. The file size of your photos
in Ente will be similar to the original file sizes you have.
## Can I add photos from a shared album to albums that I created in Ente? ## Can I add photos from a shared album to albums that I created in Ente?
Currently, Ente does not support adding photos from a shared album to your personal albums. If you want to include photos from a shared album in your own albums, you will need to ask the owner of the photos to add them to your album. Currently, Ente does not support adding photos from a shared album to your
personal albums. If you want to include photos from a shared album in your own
albums, you will need to ask the owner of the photos to add them to your album.
## How do I ensure that the Ente desktop app stays up to date on my system? ## How do I ensure that the Ente desktop app stays up to date on my system?
Ente desktop includes an auto-update feature, ensuring that whenever updates are deployed, the app will automatically download and install them. You don't need to manually update the software. Ente desktop includes an auto-update feature, ensuring that whenever updates are
deployed, the app will automatically download and install them. You don't need
to manually update the software.
## Can I sync a folder containing multiple subfolders, each representing an album? ## Can I sync a folder containing multiple subfolders, each representing an album?
Yes, when you drag and drop the folder onto the desktop app, the app will detect the multiple folders and prompt you to choose whether you want to create a single album or separate albums for each folder. Yes, when you drag and drop the folder onto the desktop app, the app will detect
the multiple folders and prompt you to choose whether you want to create a
single album or separate albums for each folder.
## What is the difference between **Magic** and **Content** search results on the desktop? ## What is the difference between **Magic** and **Content** search results on the desktop?
**Magic** is where you can search for long queries. Like, "baby in red dress", or "dog playing at the beach". **Magic** is where you can search for long queries. Like, "baby in red dress",
or "dog playing at the beach".
**Content** is where you can search for single-words. Like, "car" or "pizza". **Content** is where you can search for single-words. Like, "car" or "pizza".
## How do I identify which files experienced upload issues within the desktop app? ## How do I identify which files experienced upload issues within the desktop app?
Check the sections within the upload progress bar for "Failed Uploads," "Ignored Uploads," and "Unsuccessful Uploads." Check the sections within the upload progress bar for "Failed Uploads," "Ignored
Uploads," and "Unsuccessful Uploads."
## How do i keep NAS and Ente photos synced?
Please try using our CLI to pull data into your NAS
https://github.com/ente-io/ente/tree/main/cli#readme .
## Is there a way to view all albums on the map view?
Currently, the Ente mobile app allows you to see a map view of all the albums by
clicking on "Your map" under "Locations" on the search screen.

View file

@ -80,3 +80,10 @@ and is never sent to our servers.
Please note that only users on the paid plan are allowed to share albums. The Please note that only users on the paid plan are allowed to share albums. The
receiver just needs a free Ente account. receiver just needs a free Ente account.
## Has the Ente Photos app been audited by a credible source?
Yes, Ente Photos has undergone a thorough security audit conducted by Cure53, in
collaboration with Symbolic Software. Cure53 is a prominent German cybersecurity
firm, while Symbolic Software specializes in applied cryptography. Please find
the full report here: https://ente.io/blog/cryptography-audit/

View file

@ -159,4 +159,6 @@ We do offer a generous free trial for you to experience the product.
## Will I need to pay for Ente Auth after my Ente Photos free plan expires? ## Will I need to pay for Ente Auth after my Ente Photos free plan expires?
No, you will not need to pay for Ente Auth after your Ente Photos free plan expires. Ente Auth is completely free to use, and the expiration of your Ente Photos free plan will not impact your ability to access or use Ente Auth. No, you will not need to pay for Ente Auth after your Ente Photos free plan
expires. Ente Auth is completely free to use, and the expiration of your Ente
Photos free plan will not impact your ability to access or use Ente Auth.

View file

@ -13,7 +13,7 @@ videos you have uploaded to Ente.
![Ente - Sign in to export data](sign-in.png) ![Ente - Sign in to export data](sign-in.png)
2. Open the side bar, and select the option to **export data**. 2. Open the side bar, and select the option to **Export Data**.
![Ente - Export data](export-1.png) ![Ente - Export data](export-1.png)
@ -33,7 +33,7 @@ videos you have uploaded to Ente.
</div> </div>
5. Wait for the export to get completed. 5. Wait for the export to complete.
<div align="center"> <div align="center">
@ -42,7 +42,7 @@ videos you have uploaded to Ente.
</div> </div>
6. In case your download gets interrupted, Ente will resume from where it left 6. In case your download gets interrupted, Ente will resume from where it left
off. Simply select **export data** again and click on **Resync**. off. Simply select **Export Data** again and click on **Resync**.
<div align="center"> <div align="center">
@ -50,18 +50,20 @@ videos you have uploaded to Ente.
</div> </div>
7. **Sync continuously** : You can utilize Continuous Sync to eliminate manual ### Sync continuously
You can switch on the toggle to **Sync continuously** to eliminate manual
exports each time new photos are added to Ente. This feature automatically exports each time new photos are added to Ente. This feature automatically
detects new files and runs exports accordingly, It also ensures that exported detects new files and runs exports accordingly. It also ensures that exported
data reflects the latest album states with new files, moves, and deletions. data reflects the latest album states with new files, moves, and deletions.
![Ente - Continuous sync](continuous-sync.webp) ![Ente - Continuous sync](continuous-sync.webp)
---
If you run into any issues during your data export, please reach out to 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! [support@ente.io](mailto:support@ente.io) and we will be happy to help you!
Note that we also provide a [CLI Note that we also provide a
tool](https://github.com/ente-io/ente/tree/main/cli#export) to export your data. [CLI tool](https://github.com/ente-io/ente/tree/main/cli#export) to export your
Some more details are in this [FAQ entry](/photos/faq/export). data. Please find more details [here](/photos/faq/export).

Some files were not shown because too many files have changed in this diff Show more