Merge branch 'photos-desktop-main'
1
desktop/.eslintignore
Normal file
|
@ -0,0 +1 @@
|
|||
ui/*
|
55
desktop/.eslintrc
Normal file
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"google",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"indent": "off",
|
||||
"class-methods-use-this": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/display-name": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error"],
|
||||
"require-jsdoc": "off",
|
||||
"valid-jsdoc": "off",
|
||||
"max-len": "off",
|
||||
"new-cap": "off",
|
||||
"no-invalid-this": "off",
|
||||
"eqeqeq": "error",
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
"space-before-function-paren": "off",
|
||||
"operator-linebreak": [
|
||||
"error",
|
||||
"after",
|
||||
{ "overrides": { "?": "before", ":": "before" } }
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"JSX": "readonly",
|
||||
"NodeJS": "readonly",
|
||||
"ReadableStreamDefaultController": "readonly"
|
||||
}
|
||||
}
|
3
desktop/.github/pull_request_template.md
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
## Description
|
||||
|
||||
## Test Plan
|
58
desktop/.github/workflows/build.yml
vendored
Normal file
|
@ -0,0 +1,58 @@
|
|||
name: Build/release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Node.js, NPM and Yarn
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Prepare for app notarization
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
# Import Apple API key for app notarization on macOS
|
||||
run: |
|
||||
mkdir -p ~/private_keys/
|
||||
echo '${{ secrets.api_key }}' > ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
|
||||
|
||||
- name: Install libarchive-tools for pacman build # Related https://github.com/electron-userland/electron-builder/issues/4181
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: sudo apt-get install libarchive-tools
|
||||
|
||||
- name: Ente Electron Builder Action
|
||||
uses: ente-io/action-electron-builder@v1.0.0
|
||||
with:
|
||||
# GitHub token, automatically provided to the action
|
||||
# (No need to define this secret in the repo settings)
|
||||
github_token: ${{ secrets.github_token }}
|
||||
|
||||
# If the commit is tagged with a version (e.g. "v1.0.0"),
|
||||
# release the app after building
|
||||
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
mac_certs: ${{ secrets.mac_certs }}
|
||||
mac_certs_password: ${{ secrets.mac_certs_password }}
|
||||
env:
|
||||
# macOS notarization API key
|
||||
API_KEY_ID: ${{ secrets.api_key_id }}
|
||||
API_KEY_ISSUER_ID: ${{ secrets.api_key_issuer_id}}
|
||||
# setry crash reporting token
|
||||
SENTRY_AUTH_TOKEN: ${{secrets.sentry_auth_token}}
|
||||
NEXT_PUBLIC_DISABLE_SENTRY: ${{secrets.next_public_disable_sentry}}
|
||||
USE_HARD_LINKS: false
|
12
desktop/.gitignore
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
node_modules
|
||||
app
|
||||
.next/
|
||||
dist
|
||||
.vscode
|
||||
buildingSteps.md
|
||||
.DS_Store
|
||||
.idea/
|
||||
build/.DS_Store
|
||||
.env
|
||||
.electron-symbols/
|
||||
models/
|
8
desktop/.gitmodules
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
[submodule "bada-frame"]
|
||||
path = desktop/ui
|
||||
url = https://github.com/ente-io/bada-frame
|
||||
branch = release
|
||||
[submodule "thirdparty/next-electron-server"]
|
||||
path = desktop/thirdparty/next-electron-server
|
||||
url = https://github.com/ente-io/next-electron-server.git
|
||||
branch = desktop
|
11
desktop/.husky/pre-commit
Executable file
|
@ -0,0 +1,11 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
if [ "$branch" = "main" ]; then
|
||||
echo "You can't commit directly to main branch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npx lint-staged
|
6
desktop/.prettierrc.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true
|
||||
}
|
1
desktop/.yarnrc
Normal file
|
@ -0,0 +1 @@
|
|||
network-timeout 500000
|
213
desktop/CHANGELOG.md
Normal file
|
@ -0,0 +1,213 @@
|
|||
# CHANGELOG
|
||||
|
||||
## v1.6.63
|
||||
|
||||
### New
|
||||
|
||||
- Option to select file download location.
|
||||
- Add support for searching popular cities
|
||||
- Sorted duplicates in desecending order of size
|
||||
- Add Counter to upload section
|
||||
- Display full name and collection name on hover on dedupe screen photos
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix add to album padding issue
|
||||
- Fix double uncategorized album issue
|
||||
- Hide Hidden collection files from all section
|
||||
|
||||
## v1.6.62
|
||||
|
||||
### New
|
||||
|
||||
- Integrated onnx clip runner
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixes login button requiring double click issue
|
||||
- Fixes Collection sort state not preserved issue
|
||||
- Fixes continuous export causing app crash
|
||||
- Improves ML related copies for better distinction from clip
|
||||
- Added Better favicon for light mode
|
||||
- Fixed face indexing issues
|
||||
- Fixed thumbnail load issue
|
||||
|
||||
## v1.6.60
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix Thumbnail Orientation issue
|
||||
- Fix ML logging issue
|
||||
|
||||
## v1.6.59
|
||||
|
||||
### New
|
||||
|
||||
- Added arm64 builds for linux
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix Editor file not loading issue
|
||||
- Fix ML results missing thumbnail issue
|
||||
|
||||
## v1.6.58
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix File load issue
|
||||
|
||||
## v1.6.57
|
||||
|
||||
### New Features
|
||||
|
||||
- Added encrypted Disk caching for files
|
||||
- Added option to customize cache folder location
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed caching issue,causing multiple download of file during ml sync
|
||||
|
||||
## v1.6.55
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Added manage family portal option if add-on is active
|
||||
- Fixed filename date parsing issue
|
||||
- Fixed storage limit ui glitch
|
||||
- Fixed dedupe page layout issue
|
||||
- Fixed ElectronAPI refactoring issue
|
||||
- Fixed Search related issues
|
||||
|
||||
## v1.6.54
|
||||
|
||||
### New Features
|
||||
|
||||
- Added support for HEIC and raw image in photo editor
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed 16bit HDR HEIC images support
|
||||
- Fixed blocked login due safe storage issue
|
||||
- Fixed Search related issues
|
||||
- Fixed issue of watch folder not cleared on logout
|
||||
- other under the hood ui/ux improvements
|
||||
|
||||
## v1.6.53
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed watch folder disabled issue
|
||||
- Fixed BF Add on related issues
|
||||
- Fixed clip sync issue and added better logging
|
||||
- Fixed mov file upload
|
||||
- Fixed clip extraction related issue
|
||||
|
||||
## v1.6.52
|
||||
|
||||
### New Features
|
||||
|
||||
- Added Clip Desktop on windows
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fixed google json matching issue
|
||||
- other under-the-hood changes to improve performance and bug fixes
|
||||
|
||||
## v1.6.50
|
||||
|
||||
### New Features
|
||||
|
||||
- Added Clip desktop
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed desktop downloaded file had extra dot in the name
|
||||
- Cleanup error messages
|
||||
- fix the motion photo clustering issue
|
||||
- Add option to disable cf proxy locally
|
||||
- other under-the-hood changes to improve UX
|
||||
|
||||
## v1.6.49
|
||||
|
||||
### Photo Editor
|
||||
|
||||
Check out our [blog](https://ente.io/blog/introducing-web-desktop-photo-editor/) to know about feature and functionalities.
|
||||
|
||||
## v1.6.47
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed misaligned icons in photo-viewer
|
||||
- Fixed issue with Motion photo upload
|
||||
- Fixed issue with Live-photo upload
|
||||
- other minor ux improvement
|
||||
|
||||
## v1.6.46
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixes OOM crashes during file upload [#1379](https://github.com/ente-io/photos-web/pull/1379)
|
||||
|
||||
## v1.6.45
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed app keeps reloading issue [#235](https://github.com/ente-io/photos-desktop/pull/235)
|
||||
- Fixed dng and arw preview issue [#1378](https://github.com/ente-io/photos-web/pull/1378)
|
||||
- Added view crash report option (help menu) for user to share electron crash report locally
|
||||
|
||||
## v1.6.44
|
||||
|
||||
- Upgraded electron to get latest security patches and other improvements.
|
||||
|
||||
## v1.6.43
|
||||
|
||||
### Added
|
||||
|
||||
- #### Check for update and changelog option
|
||||
|
||||
Added options to check for update manually and a view changelog via the app menubar
|
||||
|
||||
- #### Opt out of crash reporting
|
||||
|
||||
Added option to out of a crash reporting, it can accessed from the settings -> preferences -> disable crash reporting
|
||||
|
||||
- #### Type search
|
||||
|
||||
Added new search option to search files based on file type i.e, image, video, live-photo.
|
||||
|
||||
- #### Manual Convert Button
|
||||
|
||||
In case the video is not playable, Now there is a convert button which can be used to trigger conversion of the video to supported format.
|
||||
|
||||
- #### File Download Progress
|
||||
|
||||
The file loader now also shows the exact percentage download progress, instead of just a simple loader.
|
||||
|
||||
- #### Bug fixes & other enhancements
|
||||
|
||||
We have squashed a few pesky bugs that were reported by our community
|
||||
|
||||
## v1.6.41
|
||||
|
||||
### Added
|
||||
|
||||
- #### Hidden albums
|
||||
|
||||
You can now hide albums, just like individual memories.
|
||||
|
||||
- #### Email verification
|
||||
|
||||
We have now made email verification optional, so you can sign in with just your email address and password, without waiting for a verification code.
|
||||
|
||||
You can opt in / out of email verification from Settings > Security.
|
||||
|
||||
- #### Download Album
|
||||
|
||||
You can now chose the download location for downloading albums. Along with that we have also added progress bar for album download.
|
||||
|
||||
- #### Bug fixes & other enhancements
|
||||
|
||||
We have squashed a few pesky bugs that were reported by our community
|
||||
|
||||
If you would like to help us improve ente, come join the party @ ente.io/community!
|
674
desktop/LICENSE
Normal file
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
55
desktop/README.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
# ente Photos - Desktop
|
||||
|
||||
Desktop app for [ente.io](https://ente.io) build with [electron](https://electronjs.org) and loads of ❤️.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
We are aware that electron is a sub-optimal choice for building desktop applications.
|
||||
|
||||
The goal of this app was to
|
||||
1. provide a stable environment for customers to back up large amounts of data reliably
|
||||
2. export uploaded data from our servers to their local hard drives.
|
||||
|
||||
Electron was the best way to reuse our battle tested code from [photos-web](https://github.com/ente-io/photos-web) that powers [web.ente.io](https://web.ente.io).
|
||||
|
||||
As an archival solution built by a small team, we are hopeful that this project will help us keep our stack lean, while ensuring a painfree life for our customers.
|
||||
|
||||
If you are running into issues with this app, please drop a mail to [support@ente.io](mailto:support@ente.io) and we'll be very happy to help.
|
||||
|
||||
## Download
|
||||
|
||||
- [Latest Release](https://github.com/ente-io/photos-desktop/releases/latest)
|
||||
|
||||
|
||||
## Building from source
|
||||
|
||||
You'll need to have node (and yarn) installed on your machine. e.g. on macOS you
|
||||
can do `brew install node`. After that, you can run the following commands to
|
||||
fetch and build from source.
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
git clone https://github.com/ente-io/photos-desktop
|
||||
|
||||
# Go into the repository
|
||||
cd photos-desktop
|
||||
|
||||
# Clone submodules (recursively)
|
||||
git submodule update --init --recursive
|
||||
|
||||
# Install packages
|
||||
yarn
|
||||
|
||||
# Run the app
|
||||
yarn start
|
||||
```
|
||||
|
||||
### Re-compile automatically
|
||||
|
||||
To recompile automatically and to allow using
|
||||
[electron-reload](https://github.com/yan-foto/electron-reload), run this in a
|
||||
separate terminal:
|
||||
|
||||
```bash
|
||||
yarn watch
|
||||
```
|
44
desktop/SECURITY.md
Normal file
|
@ -0,0 +1,44 @@
|
|||
ente believes that working with security researchers across the globe is crucial to keeping our
|
||||
users safe. If you believe you've found a security issue in our product or service, we encourage you to
|
||||
notify us (security@ente.io). We welcome working with you to resolve the issue promptly. Thanks in advance!
|
||||
|
||||
# Disclosure Policy
|
||||
|
||||
- Let us know as soon as possible upon discovery of a potential security issue, and we'll make every
|
||||
effort to quickly resolve the issue.
|
||||
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a
|
||||
third-party. We may publicly disclose the issue before resolving it, if appropriate.
|
||||
- Make a good faith effort to avoid privacy violations, destruction of data, and interruption or
|
||||
degradation of our service. Only interact with accounts you own or with explicit permission of the
|
||||
account holder.
|
||||
- If you would like to encrypt your report, please use the PGP key with long ID
|
||||
`E273695C0403F34F74171932DF6DDDE98EBD2394` (available in the public keyserver pool).
|
||||
|
||||
# In-scope
|
||||
|
||||
- Security issues in any current release of ente. This includes the web app, desktop app,
|
||||
and mobile apps (iOS and Android). Product downloads are available at https://ente.io. Source
|
||||
code is available at https://github.com/ente-io.
|
||||
|
||||
# Exclusions
|
||||
|
||||
The following bug classes are out-of scope:
|
||||
|
||||
- Bugs that are already reported on any of ente's issue trackers (https://github.com/ente-io),
|
||||
or that we already know of. Note that some of our issue tracking is private.
|
||||
- Issues in an upstream software dependency (ex: Flutter, Next.js etc) which are already reported to the upstream maintainer.
|
||||
- Attacks requiring physical access to a user's device.
|
||||
- Self-XSS
|
||||
- Issues related to software or protocols not under ente's control
|
||||
- Vulnerabilities in outdated versions of ente
|
||||
- Missing security best practices that do not directly lead to a vulnerability
|
||||
- Issues that do not have any impact on the general public
|
||||
|
||||
While researching, we'd like to ask you to refrain from:
|
||||
|
||||
- Denial of service
|
||||
- Spamming
|
||||
- Social engineering (including phishing) of ente staff or contractors
|
||||
- Any physical attempts against ente property or data centers
|
||||
|
||||
Thank you for helping keep ente and our users safe!
|
12
desktop/build/entitlements.mac.plist
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
20
desktop/build/error.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ente Photos</title>
|
||||
</head>
|
||||
|
||||
<body style="background-color: black;">
|
||||
<div style=" height: 95vh;width: 96vw; display: grid; place-items: center; color: white;">
|
||||
<div>
|
||||
<div style="margin-bottom: 10px;">Site unreachable, please try again later</div>
|
||||
<button onClick="window[`ElectronAPIs`].reloadWindow()">Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
BIN
desktop/build/ggmlclip-linux
Executable file
BIN
desktop/build/ggmlclip-mac
Executable file
BIN
desktop/build/ggmlclip-windows.exe
Executable file
BIN
desktop/build/icon.icns
Normal file
BIN
desktop/build/icon.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
desktop/build/image-magick
Executable file
BIN
desktop/build/msvcp140d.dll
Normal file
30
desktop/build/splash.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ente Photos</title>
|
||||
</head>
|
||||
|
||||
<body style="background-color: black;">
|
||||
<div style="display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 90vh;">
|
||||
<div style="width:64px;"><svg version="1.1" id="L9" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 100 100"
|
||||
enable-background="new 0 0 0 0" xml:space="preserve">
|
||||
<path fill="#2dc262"
|
||||
d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50">
|
||||
<animateTransform attributeName="transform" attributeType="XML" type="rotate" dur="1s"
|
||||
from="0 50 50" to="360 50 50" repeatCount="indefinite" />
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
BIN
desktop/build/taskbar-icon-Template.png
Normal file
After Width: | Height: | Size: 989 B |
BIN
desktop/build/taskbar-icon-Template@2x.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
desktop/build/taskbar-icon-Template@3x.png
Normal file
After Width: | Height: | Size: 607 B |
BIN
desktop/build/taskbar-icon.png
Normal file
After Width: | Height: | Size: 259 B |
BIN
desktop/build/taskbar-icon@2x.png
Normal file
After Width: | Height: | Size: 458 B |
BIN
desktop/build/taskbar-icon@3x.png
Normal file
After Width: | Height: | Size: 655 B |
BIN
desktop/build/ucrtbased.dll
Normal file
BIN
desktop/build/vcruntime140_1d.dll
Normal file
BIN
desktop/build/vcruntime140d.dll
Normal file
24
desktop/build/version.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Electron Updater Example</title>
|
||||
</head>
|
||||
<body>
|
||||
Current version: <span id="version">vX.Y.Z</span>
|
||||
<div id="messages"></div>
|
||||
<script>
|
||||
// Display the current version
|
||||
let version = window.location.hash.substring(1);
|
||||
document.getElementById('version').innerText = version;
|
||||
|
||||
// Listen for messages
|
||||
const {ipcRenderer} = require('electron');
|
||||
ipcRenderer.on('message', function(event, text) {
|
||||
var container = document.getElementById('messages');
|
||||
var message = document.createElement('div');
|
||||
message.innerHTML = text;
|
||||
container.appendChild(message);
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
BIN
desktop/build/window-icon.png
Normal file
After Width: | Height: | Size: 1 KiB |
25
desktop/deployment.md
Normal file
|
@ -0,0 +1,25 @@
|
|||
Notes on how to upload electron symbols directly to sentry instance (bypassing the CF limits) cc @abhi just for future reference
|
||||
|
||||
To upload electron symbols
|
||||
|
||||
1. Create a tunnel
|
||||
```
|
||||
ssh -p 7426 -N -L 8080:localhost:9000 sentry
|
||||
```
|
||||
|
||||
2. Add the following env file
|
||||
```
|
||||
NEXT_PUBLIC_IS_SENTRY_ENABLED = yes
|
||||
SENTRY_ORG = ente
|
||||
SENTRY_PROJECT = bhari-frame
|
||||
SENTRY_URL2 = https://sentry.ente.io/
|
||||
SENTRY_URL = http://localhost:8080/
|
||||
SENTRY_AUTH_TOKEN = xxx
|
||||
SENTRY_LOG_LEVEL = debug
|
||||
```
|
||||
|
||||
3. Run
|
||||
|
||||
```
|
||||
node sentry-symbols.js
|
||||
```
|
154
desktop/package.json
Normal file
|
@ -0,0 +1,154 @@
|
|||
{
|
||||
"name": "ente",
|
||||
"productName": "ente",
|
||||
"version": "1.6.63",
|
||||
"private": true,
|
||||
"description": "Desktop client for ente.io",
|
||||
"main": "app/main.js",
|
||||
"build": {
|
||||
"appId": "io.ente.bhari-frame",
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"nsis": {
|
||||
"deleteAppDataOnUninstall": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "rpm",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "pacman",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "./build/icon.icns",
|
||||
"category": "Photography"
|
||||
},
|
||||
"mac": {
|
||||
"target": {
|
||||
"target": "default",
|
||||
"arch": [
|
||||
"universal"
|
||||
]
|
||||
},
|
||||
"category": "public.app-category.photography",
|
||||
"hardenedRuntime": true,
|
||||
"x64ArchFiles": "Contents/Resources/ggmlclip-mac"
|
||||
},
|
||||
"afterSign": "electron-builder-notarize",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "build",
|
||||
"to": "resources",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/ffmpeg-static/bin/${os}/${arch}/ffmpeg",
|
||||
"node_modules/ffmpeg-static/index.js",
|
||||
"node_modules/ffmpeg-static/package.json"
|
||||
],
|
||||
"files": [
|
||||
"app/**/*",
|
||||
{
|
||||
"from": "ui/apps/photos",
|
||||
"to": "ui",
|
||||
"filter": [
|
||||
"!**/*",
|
||||
"out/**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"prebuild": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||
"prepare": "husky install",
|
||||
"lint": "eslint -c .eslintrc --ext .ts src",
|
||||
"watch": "tsc -w",
|
||||
"build-main": "yarn install && tsc",
|
||||
"start-main": "yarn build-main && electron app/main.js",
|
||||
"start-renderer": "cd ui && yarn install && yarn dev:photos",
|
||||
"start": "concurrently \"yarn start-main\" \"yarn start-renderer\"",
|
||||
"build-renderer": "cd ui && yarn install && yarn export:photos",
|
||||
"build": "yarn build-renderer && yarn build-main",
|
||||
"test-release": "cross-env IS_TEST_RELEASE=true yarn build && electron-builder --config.compression=store"
|
||||
},
|
||||
"author": "ente <code@ente.io>",
|
||||
"devDependencies": {
|
||||
"@sentry/cli": "^1.68.0",
|
||||
"@types/auto-launch": "^5.0.2",
|
||||
"@types/ffmpeg-static": "^3.0.1",
|
||||
"@types/get-folder-size": "^2.0.0",
|
||||
"@types/node": "18.15.0",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/promise-fs": "^2.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.28.0",
|
||||
"@typescript-eslint/parser": "^5.28.0",
|
||||
"concurrently": "^7.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^25.8.4",
|
||||
"electron-builder": "^24.6.4",
|
||||
"electron-builder-notarize": "^1.2.0",
|
||||
"electron-download": "^4.1.1",
|
||||
"eslint": "^7.23.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"husky": "^8.0.1",
|
||||
"lint-staged": "^13.0.1",
|
||||
"prettier": "2.5.1",
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/electron": "^2.5.1",
|
||||
"any-shell-escape": "^0.1.1",
|
||||
"auto-launch": "^5.0.5",
|
||||
"chokidar": "^3.5.3",
|
||||
"compare-versions": "^6.1.0",
|
||||
"electron-log": "^4.3.5",
|
||||
"electron-reload": "^2.0.0-alpha.1",
|
||||
"electron-store": "^8.0.1",
|
||||
"electron-updater": "^4.3.8",
|
||||
"ffmpeg-static": "^5.1.0",
|
||||
"get-folder-size": "^2.0.1",
|
||||
"html-entities": "^2.4.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"next-electron-server": "file:./thirdparty/next-electron-server",
|
||||
"node-fetch": "^2.6.7",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"onnxruntime-node": "^1.16.3",
|
||||
"promise-fs": "^2.1.1"
|
||||
},
|
||||
"standard": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{js,jsx,ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write --ignore-unknown"
|
||||
]
|
||||
}
|
||||
}
|
94
desktop/sentry-symbols.js
Normal file
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
let SentryCli;
|
||||
let download;
|
||||
|
||||
try {
|
||||
SentryCli = require('@sentry/cli');
|
||||
download = require('electron-download');
|
||||
} catch (e) {
|
||||
console.error('ERROR: Missing required packages, please run:');
|
||||
console.error('npm install --save-dev @sentry/cli electron-download');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SYMBOL_CACHE_FOLDER = '.electron-symbols';
|
||||
const sentryCli = new SentryCli('./sentry.properties');
|
||||
|
||||
async function main() {
|
||||
const version = getElectronVersion();
|
||||
if (!version) {
|
||||
console.error('Cannot detect electron version, check that electron is installed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('We are starting to download all possible electron symbols');
|
||||
console.log('We need it in order to symbolicate native crashes');
|
||||
console.log(
|
||||
'This step is only needed once whenever you update your electron version',
|
||||
);
|
||||
console.log('Just call this script again it should do everything for you.');
|
||||
|
||||
let zipPath = await downloadSymbols({
|
||||
version,
|
||||
platform: 'darwin',
|
||||
arch: 'x64',
|
||||
dsym: true,
|
||||
});
|
||||
await sentryCli.execute(['upload-dif', '-t', 'dsym', zipPath], true);
|
||||
|
||||
zipPath = await downloadSymbols({
|
||||
version,
|
||||
platform: 'win32',
|
||||
arch: 'ia32',
|
||||
symbols: true,
|
||||
});
|
||||
await sentryCli.execute(['upload-dif', '-t', 'breakpad', zipPath], true);
|
||||
|
||||
zipPath = await downloadSymbols({
|
||||
version,
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
symbols: true,
|
||||
});
|
||||
await sentryCli.execute(['upload-dif', '-t', 'breakpad', zipPath], true);
|
||||
|
||||
zipPath = await downloadSymbols({
|
||||
version,
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
symbols: true,
|
||||
});
|
||||
await sentryCli.execute(['upload-dif', '-t', 'breakpad', zipPath], true);
|
||||
|
||||
console.log('Finished downloading and uploading to Sentry');
|
||||
console.log(`Feel free to delete the ${SYMBOL_CACHE_FOLDER}`);
|
||||
}
|
||||
|
||||
function getElectronVersion() {
|
||||
try {
|
||||
return require('electron/package.json').version;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSymbols(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
download(
|
||||
{
|
||||
...options,
|
||||
cache: SYMBOL_CACHE_FOLDER,
|
||||
},
|
||||
(err, zipPath) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(zipPath);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(e => console.error(e));
|
3
desktop/sentry.properties
Normal file
|
@ -0,0 +1,3 @@
|
|||
defaults.url=https://sentry.ente.io/
|
||||
defaults.org=ente
|
||||
defaults.project=desktop-photos
|
52
desktop/src/api/cache.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { ipcRenderer } from 'electron/renderer';
|
||||
import path from 'path';
|
||||
import { existsSync, mkdir, rmSync } from 'promise-fs';
|
||||
import { DiskCache } from '../services/diskCache';
|
||||
|
||||
const ENTE_CACHE_DIR_NAME = 'ente';
|
||||
|
||||
export const getCacheDirectory = async () => {
|
||||
const customCacheDir = await getCustomCacheDirectory();
|
||||
if (customCacheDir && existsSync(customCacheDir)) {
|
||||
return customCacheDir;
|
||||
}
|
||||
const defaultSystemCacheDir = await ipcRenderer.invoke('get-path', 'cache');
|
||||
return path.join(defaultSystemCacheDir, ENTE_CACHE_DIR_NAME);
|
||||
};
|
||||
|
||||
const getCacheBucketDir = async (cacheName: string) => {
|
||||
const cacheDir = await getCacheDirectory();
|
||||
const cacheBucketDir = path.join(cacheDir, cacheName);
|
||||
return cacheBucketDir;
|
||||
};
|
||||
|
||||
export async function openDiskCache(
|
||||
cacheName: string,
|
||||
cacheLimitInBytes?: number
|
||||
) {
|
||||
const cacheBucketDir = await getCacheBucketDir(cacheName);
|
||||
if (!existsSync(cacheBucketDir)) {
|
||||
await mkdir(cacheBucketDir, { recursive: true });
|
||||
}
|
||||
return new DiskCache(cacheBucketDir, cacheLimitInBytes);
|
||||
}
|
||||
|
||||
export async function deleteDiskCache(cacheName: string) {
|
||||
const cacheBucketDir = await getCacheBucketDir(cacheName);
|
||||
if (existsSync(cacheBucketDir)) {
|
||||
rmSync(cacheBucketDir, { recursive: true, force: true });
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCustomCacheDirectory(
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
await ipcRenderer.invoke('set-custom-cache-directory', directory);
|
||||
}
|
||||
|
||||
async function getCustomCacheDirectory(): Promise<string> {
|
||||
return await ipcRenderer.invoke('get-custom-cache-directory');
|
||||
}
|
55
desktop/src/api/clip.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { ipcRenderer } from 'electron';
|
||||
import { writeStream } from '../services/fs';
|
||||
import { isExecError } from '../utils/error';
|
||||
import { parseExecError } from '../utils/error';
|
||||
import { Model } from '../types';
|
||||
|
||||
export async function computeImageEmbedding(
|
||||
model: Model,
|
||||
imageData: Uint8Array
|
||||
): Promise<Float32Array> {
|
||||
let tempInputFilePath = null;
|
||||
try {
|
||||
tempInputFilePath = await ipcRenderer.invoke('get-temp-file-path', '');
|
||||
const imageStream = new Response(imageData.buffer).body;
|
||||
await writeStream(tempInputFilePath, imageStream);
|
||||
const embedding = await ipcRenderer.invoke(
|
||||
'compute-image-embedding',
|
||||
model,
|
||||
tempInputFilePath
|
||||
);
|
||||
return embedding;
|
||||
} catch (err) {
|
||||
if (isExecError(err)) {
|
||||
const parsedExecError = parseExecError(err);
|
||||
throw Error(parsedExecError);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (tempInputFilePath) {
|
||||
await ipcRenderer.invoke('remove-temp-file', tempInputFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function computeTextEmbedding(
|
||||
model: Model,
|
||||
text: string
|
||||
): Promise<Float32Array> {
|
||||
try {
|
||||
const embedding = await ipcRenderer.invoke(
|
||||
'compute-text-embedding',
|
||||
model,
|
||||
text
|
||||
);
|
||||
return embedding;
|
||||
} catch (err) {
|
||||
if (isExecError(err)) {
|
||||
const parsedExecError = parseExecError(err);
|
||||
throw Error(parsedExecError);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
44
desktop/src/api/common.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { ipcRenderer } from 'electron/renderer';
|
||||
import { logError } from '../services/logging';
|
||||
|
||||
export const selectDirectory = async (): Promise<string> => {
|
||||
try {
|
||||
return await ipcRenderer.invoke('select-dir');
|
||||
} catch (e) {
|
||||
logError(e, 'error while selecting root directory');
|
||||
}
|
||||
};
|
||||
|
||||
export const getAppVersion = async (): Promise<string> => {
|
||||
try {
|
||||
return await ipcRenderer.invoke('get-app-version');
|
||||
} catch (e) {
|
||||
logError(e, 'failed to get release version');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const openDirectory = async (dirPath: string): Promise<void> => {
|
||||
try {
|
||||
await ipcRenderer.invoke('open-dir', dirPath);
|
||||
} catch (e) {
|
||||
logError(e, 'error while opening directory');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlatform = async (): Promise<'mac' | 'windows' | 'linux'> => {
|
||||
try {
|
||||
return await ipcRenderer.invoke('get-platform');
|
||||
} catch (e) {
|
||||
logError(e, 'failed to get platform');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
logToDisk,
|
||||
openLogDirectory,
|
||||
getSentryUserID,
|
||||
updateOptOutOfCrashReports,
|
||||
} from '../services/logging';
|
19
desktop/src/api/electronStore.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { keysStore } from '../stores/keys.store';
|
||||
import { safeStorageStore } from '../stores/safeStorage.store';
|
||||
import { uploadStatusStore } from '../stores/upload.store';
|
||||
import { logError } from '../services/logging';
|
||||
import { userPreferencesStore } from '../stores/userPreferences.store';
|
||||
import { watchStore } from '../stores/watch.store';
|
||||
|
||||
export const clearElectronStore = () => {
|
||||
try {
|
||||
uploadStatusStore.clear();
|
||||
keysStore.clear();
|
||||
safeStorageStore.clear();
|
||||
watchStore.clear();
|
||||
userPreferencesStore.delete('optOutOfCrashReports');
|
||||
} catch (e) {
|
||||
logError(e, 'error while clearing electron store');
|
||||
throw e;
|
||||
}
|
||||
};
|
23
desktop/src/api/export.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { writeStream } from './../services/fs';
|
||||
import * as fs from 'promise-fs';
|
||||
|
||||
export const exists = (path: string) => {
|
||||
return fs.existsSync(path);
|
||||
};
|
||||
|
||||
export const checkExistsAndCreateDir = async (dirPath: string) => {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
await fs.mkdir(dirPath);
|
||||
}
|
||||
};
|
||||
|
||||
export const saveStreamToDisk = async (
|
||||
filePath: string,
|
||||
fileStream: ReadableStream<Uint8Array>
|
||||
) => {
|
||||
await writeStream(filePath, fileStream);
|
||||
};
|
||||
|
||||
export const saveFileToDisk = async (path: string, fileData: string) => {
|
||||
await fs.writeFile(path, fileData);
|
||||
};
|
44
desktop/src/api/ffmpeg.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { ipcRenderer } from 'electron';
|
||||
import { existsSync } from 'fs';
|
||||
import { writeStream } from '../services/fs';
|
||||
import { logError } from '../services/logging';
|
||||
import { ElectronFile } from '../types';
|
||||
|
||||
export async function runFFmpegCmd(
|
||||
cmd: string[],
|
||||
inputFile: File | ElectronFile,
|
||||
outputFileName: string,
|
||||
dontTimeout?: boolean
|
||||
) {
|
||||
let inputFilePath = null;
|
||||
let createdTempInputFile = null;
|
||||
try {
|
||||
if (!existsSync(inputFile.path)) {
|
||||
const tempFilePath = await ipcRenderer.invoke(
|
||||
'get-temp-file-path',
|
||||
inputFile.name
|
||||
);
|
||||
await writeStream(tempFilePath, await inputFile.stream());
|
||||
inputFilePath = tempFilePath;
|
||||
createdTempInputFile = true;
|
||||
} else {
|
||||
inputFilePath = inputFile.path;
|
||||
}
|
||||
const outputFileData = await ipcRenderer.invoke(
|
||||
'run-ffmpeg-cmd',
|
||||
cmd,
|
||||
inputFilePath,
|
||||
outputFileName,
|
||||
dontTimeout
|
||||
);
|
||||
return new File([outputFileData], outputFileName);
|
||||
} finally {
|
||||
if (createdTempInputFile) {
|
||||
try {
|
||||
await ipcRenderer.invoke('remove-temp-file', inputFilePath);
|
||||
} catch (e) {
|
||||
logError(e, 'failed to deleteTempFile');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
desktop/src/api/fs.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { getElectronFile, getDirFilePaths } from '../services/fs';
|
||||
|
||||
export async function getDirFiles(dirPath: string) {
|
||||
const files = await getDirFilePaths(dirPath);
|
||||
const electronFiles = await Promise.all(files.map(getElectronFile));
|
||||
return electronFiles;
|
||||
}
|
||||
export {
|
||||
isFolder,
|
||||
moveFile,
|
||||
deleteFolder,
|
||||
deleteFile,
|
||||
rename,
|
||||
readTextFile,
|
||||
} from '../services/fs';
|
64
desktop/src/api/imageProcessor.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { CustomErrors } from '../constants/errors';
|
||||
import { ipcRenderer } from 'electron/renderer';
|
||||
import { existsSync } from 'fs';
|
||||
import { writeStream } from '../services/fs';
|
||||
import { logError } from '../services/logging';
|
||||
import { ElectronFile } from '../types';
|
||||
import { isPlatform } from '../utils/common/platform';
|
||||
|
||||
export async function convertToJPEG(
|
||||
fileData: Uint8Array,
|
||||
filename: string
|
||||
): Promise<Uint8Array> {
|
||||
if (isPlatform('windows')) {
|
||||
throw Error(CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED);
|
||||
}
|
||||
const convertedFileData = await ipcRenderer.invoke(
|
||||
'convert-to-jpeg',
|
||||
fileData,
|
||||
filename
|
||||
);
|
||||
return convertedFileData;
|
||||
}
|
||||
|
||||
export async function generateImageThumbnail(
|
||||
inputFile: File | ElectronFile,
|
||||
maxDimension: number,
|
||||
maxSize: number
|
||||
): Promise<Uint8Array> {
|
||||
let inputFilePath = null;
|
||||
let createdTempInputFile = null;
|
||||
try {
|
||||
if (isPlatform('windows')) {
|
||||
throw Error(
|
||||
CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED
|
||||
);
|
||||
}
|
||||
if (!existsSync(inputFile.path)) {
|
||||
const tempFilePath = await ipcRenderer.invoke(
|
||||
'get-temp-file-path',
|
||||
inputFile.name
|
||||
);
|
||||
await writeStream(tempFilePath, await inputFile.stream());
|
||||
inputFilePath = tempFilePath;
|
||||
createdTempInputFile = true;
|
||||
} else {
|
||||
inputFilePath = inputFile.path;
|
||||
}
|
||||
const thumbnail = await ipcRenderer.invoke(
|
||||
'generate-image-thumbnail',
|
||||
inputFilePath,
|
||||
maxDimension,
|
||||
maxSize
|
||||
);
|
||||
return thumbnail;
|
||||
} finally {
|
||||
if (createdTempInputFile) {
|
||||
try {
|
||||
await ipcRenderer.invoke('remove-temp-file', inputFilePath);
|
||||
} catch (e) {
|
||||
logError(e, 'failed to deleteTempFile');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
desktop/src/api/safeStorage.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { ipcRenderer } from 'electron';
|
||||
import { safeStorageStore } from '../stores/safeStorage.store';
|
||||
import { logError } from '../services/logging';
|
||||
|
||||
export async function setEncryptionKey(encryptionKey: string) {
|
||||
try {
|
||||
const encryptedKey: Buffer = await ipcRenderer.invoke(
|
||||
'safeStorage-encrypt',
|
||||
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 = new Uint8Array(
|
||||
Buffer.from(b64EncryptedKey, 'base64')
|
||||
);
|
||||
return await ipcRenderer.invoke('safeStorage-decrypt', keyBuffer);
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, 'getEncryptionKey failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
37
desktop/src/api/system.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { ipcRenderer } from 'electron';
|
||||
import { AppUpdateInfo } from '../types';
|
||||
|
||||
export const sendNotification = (content: string) => {
|
||||
ipcRenderer.send('send-notification', content);
|
||||
};
|
||||
export const reloadWindow = () => {
|
||||
ipcRenderer.send('reload-window');
|
||||
};
|
||||
|
||||
export const registerUpdateEventListener = (
|
||||
showUpdateDialog: (updateInfo: AppUpdateInfo) => void
|
||||
) => {
|
||||
ipcRenderer.removeAllListeners('show-update-dialog');
|
||||
ipcRenderer.on('show-update-dialog', (_, updateInfo: AppUpdateInfo) => {
|
||||
showUpdateDialog(updateInfo);
|
||||
});
|
||||
};
|
||||
|
||||
export const registerForegroundEventListener = (onForeground: () => void) => {
|
||||
ipcRenderer.removeAllListeners('app-in-foreground');
|
||||
ipcRenderer.on('app-in-foreground', () => {
|
||||
onForeground();
|
||||
});
|
||||
};
|
||||
|
||||
export const updateAndRestart = () => {
|
||||
ipcRenderer.send('update-and-restart');
|
||||
};
|
||||
|
||||
export const skipAppUpdate = (version: string) => {
|
||||
ipcRenderer.send('skip-app-update', version);
|
||||
};
|
||||
|
||||
export const muteUpdateNotification = (version: string) => {
|
||||
ipcRenderer.send('mute-update-notification', version);
|
||||
};
|
90
desktop/src/api/upload.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { getElectronFile } from './../services/fs';
|
||||
import { uploadStatusStore } from '../stores/upload.store';
|
||||
import { ElectronFile, FILE_PATH_TYPE } from '../types';
|
||||
import { logError } from '../services/logging';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import {
|
||||
getElectronFilesFromGoogleZip,
|
||||
getSavedFilePaths,
|
||||
} from '../services/upload';
|
||||
|
||||
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 showUploadDirsDialog = async () => {
|
||||
try {
|
||||
const filePaths: string[] = await ipcRenderer.invoke(
|
||||
'show-upload-dirs-dialog'
|
||||
);
|
||||
const files = await Promise.all(filePaths.map(getElectronFile));
|
||||
return files;
|
||||
} catch (e) {
|
||||
logError(e, 'error while selecting folders');
|
||||
}
|
||||
};
|
||||
|
||||
export const showUploadFilesDialog = async () => {
|
||||
try {
|
||||
const filePaths: string[] = await ipcRenderer.invoke(
|
||||
'show-upload-files-dialog'
|
||||
);
|
||||
const files = await Promise.all(filePaths.map(getElectronFile));
|
||||
return files;
|
||||
} catch (e) {
|
||||
logError(e, 'error while selecting files');
|
||||
}
|
||||
};
|
||||
|
||||
export const showUploadZipDialog = async () => {
|
||||
try {
|
||||
const filePaths: string[] = await ipcRenderer.invoke(
|
||||
'show-upload-zip-dialog'
|
||||
);
|
||||
let files: ElectronFile[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
files = [
|
||||
...files,
|
||||
...(await getElectronFilesFromGoogleZip(filePath)),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
zipPaths: filePaths,
|
||||
files,
|
||||
};
|
||||
} catch (e) {
|
||||
logError(e, 'error while selecting zips');
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
setToUploadFiles,
|
||||
getElectronFilesFromGoogleZip,
|
||||
setToUploadCollection,
|
||||
} from '../services/upload';
|
114
desktop/src/api/watch.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { isMappingPresent } from '../utils/watch';
|
||||
import path from 'path';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { ElectronFile, WatchMapping } from '../types';
|
||||
import { getElectronFile } from '../services/fs';
|
||||
import { getWatchMappings, setWatchMappings } from '../services/watch';
|
||||
import ElectronLog from 'electron-log';
|
||||
|
||||
export async function addWatchMapping(
|
||||
rootFolderName: string,
|
||||
folderPath: string,
|
||||
uploadStrategy: number
|
||||
) {
|
||||
ElectronLog.log(`Adding watch mapping: ${folderPath}`);
|
||||
const watchMappings = getWatchMappings();
|
||||
if (isMappingPresent(watchMappings, folderPath)) {
|
||||
throw new Error(`Watch mapping already exists`);
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('add-watcher', {
|
||||
dir: folderPath,
|
||||
});
|
||||
|
||||
watchMappings.push({
|
||||
rootFolderName,
|
||||
uploadStrategy,
|
||||
folderPath,
|
||||
syncedFiles: [],
|
||||
ignoredFiles: [],
|
||||
});
|
||||
|
||||
setWatchMappings(watchMappings);
|
||||
}
|
||||
|
||||
export async function removeWatchMapping(folderPath: string) {
|
||||
let watchMappings = getWatchMappings();
|
||||
const watchMapping = watchMappings.find(
|
||||
(mapping) => mapping.folderPath === folderPath
|
||||
);
|
||||
|
||||
if (!watchMapping) {
|
||||
throw new Error(`Watch mapping does not exist`);
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('remove-watcher', {
|
||||
dir: watchMapping.folderPath,
|
||||
});
|
||||
|
||||
watchMappings = watchMappings.filter(
|
||||
(mapping) => mapping.folderPath !== watchMapping.folderPath
|
||||
);
|
||||
|
||||
setWatchMappings(watchMappings);
|
||||
}
|
||||
|
||||
export function updateWatchMappingSyncedFiles(
|
||||
folderPath: string,
|
||||
files: WatchMapping['syncedFiles']
|
||||
): void {
|
||||
const watchMappings = getWatchMappings();
|
||||
const watchMapping = watchMappings.find(
|
||||
(mapping) => mapping.folderPath === folderPath
|
||||
);
|
||||
|
||||
if (!watchMapping) {
|
||||
throw Error(`Watch mapping not found`);
|
||||
}
|
||||
|
||||
watchMapping.syncedFiles = files;
|
||||
setWatchMappings(watchMappings);
|
||||
}
|
||||
|
||||
export function updateWatchMappingIgnoredFiles(
|
||||
folderPath: string,
|
||||
files: WatchMapping['ignoredFiles']
|
||||
): void {
|
||||
const watchMappings = getWatchMappings();
|
||||
const watchMapping = watchMappings.find(
|
||||
(mapping) => mapping.folderPath === folderPath
|
||||
);
|
||||
|
||||
if (!watchMapping) {
|
||||
throw Error(`Watch mapping not found`);
|
||||
}
|
||||
|
||||
watchMapping.ignoredFiles = files;
|
||||
setWatchMappings(watchMappings);
|
||||
}
|
||||
|
||||
export function registerWatcherFunctions(
|
||||
addFile: (file: ElectronFile) => Promise<void>,
|
||||
removeFile: (path: string) => Promise<void>,
|
||||
removeFolder: (folderPath: string) => Promise<void>
|
||||
) {
|
||||
ipcRenderer.removeAllListeners('watch-add');
|
||||
ipcRenderer.removeAllListeners('watch-change');
|
||||
ipcRenderer.removeAllListeners('watch-unlink-dir');
|
||||
ipcRenderer.on('watch-add', async (_, filePath: string) => {
|
||||
filePath = filePath.split(path.sep).join(path.posix.sep);
|
||||
|
||||
await addFile(await getElectronFile(filePath));
|
||||
});
|
||||
ipcRenderer.on('watch-unlink', async (_, filePath: string) => {
|
||||
filePath = filePath.split(path.sep).join(path.posix.sep);
|
||||
|
||||
await removeFile(filePath);
|
||||
});
|
||||
ipcRenderer.on('watch-unlink-dir', async (_, folderPath: string) => {
|
||||
folderPath = folderPath.split(path.sep).join(path.posix.sep);
|
||||
await removeFolder(folderPath);
|
||||
});
|
||||
}
|
||||
|
||||
export { getWatchMappings } from '../services/watch';
|
20
desktop/src/config/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
const PROD_HOST_URL: string = 'ente://app';
|
||||
const RENDERER_OUTPUT_DIR: string = './ui/out';
|
||||
const LOG_FILENAME = 'ente.log';
|
||||
const MAX_LOG_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
const FILE_STREAM_CHUNK_SIZE: number = 4 * 1024 * 1024;
|
||||
|
||||
const SENTRY_DSN = 'https://759d8498487a81ac33a0c2efa2a42c4f@sentry.ente.io/9';
|
||||
|
||||
const RELEASE_VERSION = require('../../package.json').version;
|
||||
|
||||
export {
|
||||
PROD_HOST_URL,
|
||||
RENDERER_OUTPUT_DIR,
|
||||
FILE_STREAM_CHUNK_SIZE,
|
||||
LOG_FILENAME,
|
||||
MAX_LOG_SIZE,
|
||||
SENTRY_DSN,
|
||||
RELEASE_VERSION,
|
||||
};
|
12
desktop/src/constants/errors.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
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}`,
|
||||
};
|
105
desktop/src/main.ts
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { app, BrowserWindow } from 'electron';
|
||||
import { createWindow } from './utils/createWindow';
|
||||
import setupIpcComs from './utils/ipcComms';
|
||||
import { initWatcher } from './services/chokidar';
|
||||
import { addAllowOriginHeader } from './utils/cors';
|
||||
import {
|
||||
setupTrayItem,
|
||||
handleDownloads,
|
||||
setupMacWindowOnDockIconClick,
|
||||
setupMainMenu,
|
||||
setupMainHotReload,
|
||||
setupNextElectronServe,
|
||||
enableSharedArrayBufferSupport,
|
||||
handleDockIconHideOnAutoLaunch,
|
||||
handleUpdates,
|
||||
logSystemInfo,
|
||||
handleExternalLinks,
|
||||
} from './utils/main';
|
||||
import { initSentry } from './services/sentry';
|
||||
import { setupLogging } from './utils/logging';
|
||||
import { isDev } from './utils/common';
|
||||
import { setupMainProcessStatsLogger } from './utils/processStats';
|
||||
import { setupAppEventEmitter } from './utils/events';
|
||||
import { getOptOutOfCrashReports } from './services/userPreference';
|
||||
|
||||
let mainWindow: BrowserWindow;
|
||||
|
||||
let appIsQuitting = false;
|
||||
|
||||
let updateIsAvailable = false;
|
||||
|
||||
let optedOutOfCrashReports = 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;
|
||||
};
|
||||
|
||||
export const hasOptedOutOfCrashReports = (): boolean => {
|
||||
return optedOutOfCrashReports;
|
||||
};
|
||||
|
||||
export const updateOptOutOfCrashReports = (value: boolean): void => {
|
||||
optedOutOfCrashReports = value;
|
||||
};
|
||||
|
||||
setupMainHotReload();
|
||||
|
||||
setupNextElectronServe();
|
||||
|
||||
setupLogging(isDev);
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
handleDockIconHideOnAutoLaunch();
|
||||
enableSharedArrayBufferSupport();
|
||||
app.on('second-instance', () => {
|
||||
// Someone tried to run a second instance, we should focus our window.
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on('ready', async () => {
|
||||
logSystemInfo();
|
||||
setupMainProcessStatsLogger();
|
||||
const hasOptedOutOfCrashReports = getOptOutOfCrashReports();
|
||||
updateOptOutOfCrashReports(hasOptedOutOfCrashReports);
|
||||
if (!hasOptedOutOfCrashReports) {
|
||||
initSentry();
|
||||
}
|
||||
mainWindow = await createWindow();
|
||||
const tray = setupTrayItem(mainWindow);
|
||||
const watcher = initWatcher(mainWindow);
|
||||
setupMacWindowOnDockIconClick();
|
||||
setupMainMenu(mainWindow);
|
||||
setupIpcComs(tray, mainWindow, watcher);
|
||||
await handleUpdates(mainWindow);
|
||||
handleDownloads(mainWindow);
|
||||
handleExternalLinks(mainWindow);
|
||||
addAllowOriginHeader(mainWindow);
|
||||
setupAppEventEmitter(mainWindow);
|
||||
});
|
||||
|
||||
app.on('before-quit', () => setIsAppQuitting(true));
|
||||
}
|
129
desktop/src/preload.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
import {
|
||||
registerUpdateEventListener,
|
||||
reloadWindow,
|
||||
sendNotification,
|
||||
updateAndRestart,
|
||||
skipAppUpdate,
|
||||
muteUpdateNotification,
|
||||
registerForegroundEventListener,
|
||||
} from './api/system';
|
||||
import {
|
||||
showUploadDirsDialog,
|
||||
showUploadFilesDialog,
|
||||
showUploadZipDialog,
|
||||
getPendingUploads,
|
||||
setToUploadFiles,
|
||||
getElectronFilesFromGoogleZip,
|
||||
setToUploadCollection,
|
||||
} from './api/upload';
|
||||
import {
|
||||
registerWatcherFunctions,
|
||||
addWatchMapping,
|
||||
removeWatchMapping,
|
||||
updateWatchMappingSyncedFiles,
|
||||
updateWatchMappingIgnoredFiles,
|
||||
getWatchMappings,
|
||||
} from './api/watch';
|
||||
import { getEncryptionKey, setEncryptionKey } from './api/safeStorage';
|
||||
import { clearElectronStore } from './api/electronStore';
|
||||
import {
|
||||
openDiskCache,
|
||||
deleteDiskCache,
|
||||
getCacheDirectory,
|
||||
setCustomCacheDirectory,
|
||||
} from './api/cache';
|
||||
import {
|
||||
checkExistsAndCreateDir,
|
||||
saveStreamToDisk,
|
||||
saveFileToDisk,
|
||||
exists,
|
||||
} from './api/export';
|
||||
import {
|
||||
selectDirectory,
|
||||
logToDisk,
|
||||
openLogDirectory,
|
||||
getSentryUserID,
|
||||
getAppVersion,
|
||||
openDirectory,
|
||||
updateOptOutOfCrashReports,
|
||||
getPlatform,
|
||||
} from './api/common';
|
||||
import { fixHotReloadNext12 } from './utils/preload';
|
||||
import {
|
||||
isFolder,
|
||||
getDirFiles,
|
||||
moveFile,
|
||||
deleteFolder,
|
||||
rename,
|
||||
readTextFile,
|
||||
deleteFile,
|
||||
} from './api/fs';
|
||||
import { convertToJPEG, generateImageThumbnail } from './api/imageProcessor';
|
||||
import { setupLogging } from './utils/logging';
|
||||
import {
|
||||
setupRendererProcessStatsLogger,
|
||||
logRendererProcessMemoryUsage,
|
||||
} from './utils/processStats';
|
||||
import { runFFmpegCmd } from './api/ffmpeg';
|
||||
import { computeImageEmbedding, computeTextEmbedding } from './api/clip';
|
||||
|
||||
fixHotReloadNext12();
|
||||
setupLogging();
|
||||
setupRendererProcessStatsLogger();
|
||||
|
||||
const windowObject: any = window;
|
||||
|
||||
windowObject['ElectronAPIs'] = {
|
||||
exists,
|
||||
checkExistsAndCreateDir,
|
||||
saveStreamToDisk,
|
||||
saveFileToDisk,
|
||||
selectDirectory,
|
||||
clearElectronStore,
|
||||
sendNotification,
|
||||
reloadWindow,
|
||||
readTextFile,
|
||||
showUploadFilesDialog,
|
||||
showUploadDirsDialog,
|
||||
getPendingUploads,
|
||||
setToUploadFiles,
|
||||
showUploadZipDialog,
|
||||
getElectronFilesFromGoogleZip,
|
||||
setToUploadCollection,
|
||||
getEncryptionKey,
|
||||
setEncryptionKey,
|
||||
openDiskCache,
|
||||
deleteDiskCache,
|
||||
getDirFiles,
|
||||
getWatchMappings,
|
||||
addWatchMapping,
|
||||
removeWatchMapping,
|
||||
registerWatcherFunctions,
|
||||
isFolder,
|
||||
updateWatchMappingSyncedFiles,
|
||||
updateWatchMappingIgnoredFiles,
|
||||
logToDisk,
|
||||
convertToJPEG,
|
||||
openLogDirectory,
|
||||
registerUpdateEventListener,
|
||||
updateAndRestart,
|
||||
skipAppUpdate,
|
||||
getSentryUserID,
|
||||
getAppVersion,
|
||||
runFFmpegCmd,
|
||||
muteUpdateNotification,
|
||||
generateImageThumbnail,
|
||||
logRendererProcessMemoryUsage,
|
||||
registerForegroundEventListener,
|
||||
openDirectory,
|
||||
moveFile,
|
||||
deleteFolder,
|
||||
rename,
|
||||
deleteFile,
|
||||
updateOptOutOfCrashReports,
|
||||
computeImageEmbedding,
|
||||
computeTextEmbedding,
|
||||
getPlatform,
|
||||
getCacheDirectory,
|
||||
setCustomCacheDirectory,
|
||||
};
|
159
desktop/src/services/appUpdater.ts
Normal file
|
@ -0,0 +1,159 @@
|
|||
import { app, BrowserWindow } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import log from 'electron-log';
|
||||
import { setIsAppQuitting, setIsUpdateAvailable } from '../main';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { AppUpdateInfo, GetFeatureFlagResponse } from '../types';
|
||||
import {
|
||||
clearMuteUpdateNotificationVersion,
|
||||
clearSkipAppVersion,
|
||||
getMuteUpdateNotificationVersion,
|
||||
getSkipAppVersion,
|
||||
setMuteUpdateNotificationVersion,
|
||||
setSkipAppVersion,
|
||||
} from './userPreference';
|
||||
import fetch from 'node-fetch';
|
||||
import { logErrorSentry } from './sentry';
|
||||
import ElectronLog from 'electron-log';
|
||||
import { isPlatform } from '../utils/common/platform';
|
||||
|
||||
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;
|
||||
}
|
||||
const desktopCutoffVersion = await getDesktopCutoffVersion();
|
||||
if (
|
||||
desktopCutoffVersion &&
|
||||
isPlatform('mac') &&
|
||||
compareVersions(
|
||||
updateCheckResult.updateInfo.version,
|
||||
desktopCutoffVersion
|
||||
) > 0
|
||||
) {
|
||||
log.debug('auto update not possible due to key change');
|
||||
showUpdateDialog(mainWindow, {
|
||||
autoUpdatable: false,
|
||||
version: updateCheckResult.updateInfo.version,
|
||||
});
|
||||
} else {
|
||||
let timeout: NodeJS.Timeout;
|
||||
log.debug('attempting auto update');
|
||||
autoUpdater.downloadUpdate();
|
||||
const muteUpdateNotificationVersion =
|
||||
getMuteUpdateNotificationVersion();
|
||||
if (
|
||||
muteUpdateNotificationVersion &&
|
||||
updateCheckResult.updateInfo.version ===
|
||||
muteUpdateNotificationVersion
|
||||
) {
|
||||
log.info(
|
||||
'user chose to mute update notification for version ',
|
||||
updateCheckResult.updateInfo.version
|
||||
);
|
||||
return;
|
||||
}
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
showUpdateDialog(mainWindow, {
|
||||
autoUpdatable: true,
|
||||
version: updateCheckResult.updateInfo.version,
|
||||
}),
|
||||
FIVE_MIN_IN_MICROSECOND
|
||||
);
|
||||
});
|
||||
autoUpdater.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
logErrorSentry(error, 'auto update failed');
|
||||
showUpdateDialog(mainWindow, {
|
||||
autoUpdatable: false,
|
||||
version: updateCheckResult.updateInfo.version,
|
||||
});
|
||||
});
|
||||
}
|
||||
setIsUpdateAvailable(true);
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'checkForUpdateAndNotify failed');
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAndRestart() {
|
||||
ElectronLog.log('user quit the app');
|
||||
setIsAppQuitting(true);
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
||||
export function getAppVersion() {
|
||||
return `v${app.getVersion()}`;
|
||||
}
|
||||
|
||||
export function skipAppUpdate(version: string) {
|
||||
setSkipAppVersion(version);
|
||||
}
|
||||
|
||||
export function muteUpdateNotification(version: string) {
|
||||
setMuteUpdateNotificationVersion(version);
|
||||
}
|
||||
|
||||
async function getDesktopCutoffVersion() {
|
||||
try {
|
||||
const featureFlags = (
|
||||
await fetch('https://static.ente.io/feature_flags.json')
|
||||
).json() as GetFeatureFlagResponse;
|
||||
return featureFlags.desktopCutoffVersion;
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'failed to get feature flags');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function showUpdateDialog(
|
||||
mainWindow: BrowserWindow,
|
||||
updateInfo: AppUpdateInfo
|
||||
) {
|
||||
mainWindow.webContents.send('show-update-dialog', updateInfo);
|
||||
}
|
41
desktop/src/services/autoLauncher.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { isPlatform } from '../utils/common/platform';
|
||||
import { AutoLauncherClient } from '../types/autoLauncher';
|
||||
import linuxAndWinAutoLauncher from './autoLauncherClients/linuxAndWinAutoLauncher';
|
||||
import macAutoLauncher from './autoLauncherClients/macAutoLauncher';
|
||||
|
||||
class AutoLauncher {
|
||||
private client: AutoLauncherClient;
|
||||
async init() {
|
||||
if (isPlatform('linux') || isPlatform('windows')) {
|
||||
this.client = linuxAndWinAutoLauncher;
|
||||
} else {
|
||||
this.client = macAutoLauncher;
|
||||
}
|
||||
// migrate old auto launch settings for windows from mac auto launcher to linux and windows auto launcher
|
||||
if (isPlatform('windows') && (await macAutoLauncher.isEnabled())) {
|
||||
await macAutoLauncher.toggleAutoLaunch();
|
||||
await linuxAndWinAutoLauncher.toggleAutoLaunch();
|
||||
}
|
||||
}
|
||||
async isEnabled() {
|
||||
if (!this.client) {
|
||||
await this.init();
|
||||
}
|
||||
return await this.client.isEnabled();
|
||||
}
|
||||
async toggleAutoLaunch() {
|
||||
if (!this.client) {
|
||||
await this.init();
|
||||
}
|
||||
await this.client.toggleAutoLaunch();
|
||||
}
|
||||
|
||||
async wasAutoLaunched() {
|
||||
if (!this.client) {
|
||||
await this.init();
|
||||
}
|
||||
return this.client.wasAutoLaunched();
|
||||
}
|
||||
}
|
||||
|
||||
export default new AutoLauncher();
|
|
@ -0,0 +1,39 @@
|
|||
import AutoLaunch from 'auto-launch';
|
||||
import { AutoLauncherClient } from '../../types/autoLauncher';
|
||||
import { app } from 'electron';
|
||||
|
||||
const LAUNCHED_AS_HIDDEN_FLAG = 'hidden';
|
||||
|
||||
class LinuxAndWinAutoLauncher implements AutoLauncherClient {
|
||||
private instance: AutoLaunch;
|
||||
constructor() {
|
||||
const autoLauncher = new AutoLaunch({
|
||||
name: 'ente',
|
||||
isHidden: true,
|
||||
});
|
||||
this.instance = autoLauncher;
|
||||
}
|
||||
async isEnabled() {
|
||||
return await this.instance.isEnabled();
|
||||
}
|
||||
async toggleAutoLaunch() {
|
||||
if (await this.isEnabled()) {
|
||||
await this.disableAutoLaunch();
|
||||
} else {
|
||||
await this.enableAutoLaunch();
|
||||
}
|
||||
}
|
||||
|
||||
async wasAutoLaunched() {
|
||||
return app.commandLine.hasSwitch(LAUNCHED_AS_HIDDEN_FLAG);
|
||||
}
|
||||
|
||||
private async disableAutoLaunch() {
|
||||
await this.instance.disable();
|
||||
}
|
||||
private async enableAutoLaunch() {
|
||||
await this.instance.enable();
|
||||
}
|
||||
}
|
||||
|
||||
export default new LinuxAndWinAutoLauncher();
|
28
desktop/src/services/autoLauncherClients/macAutoLauncher.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { app } from 'electron';
|
||||
import { AutoLauncherClient } from '../../types/autoLauncher';
|
||||
|
||||
class MacAutoLauncher implements AutoLauncherClient {
|
||||
async isEnabled() {
|
||||
return app.getLoginItemSettings().openAtLogin;
|
||||
}
|
||||
async toggleAutoLaunch() {
|
||||
if (await this.isEnabled()) {
|
||||
this.disableAutoLaunch();
|
||||
} else {
|
||||
this.enableAutoLaunch();
|
||||
}
|
||||
}
|
||||
|
||||
async wasAutoLaunched() {
|
||||
return app.getLoginItemSettings().wasOpenedAtLogin;
|
||||
}
|
||||
|
||||
private disableAutoLaunch() {
|
||||
app.setLoginItemSettings({ openAtLogin: false });
|
||||
}
|
||||
private enableAutoLaunch() {
|
||||
app.setLoginItemSettings({ openAtLogin: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MacAutoLauncher();
|
33
desktop/src/services/chokidar.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import chokidar from 'chokidar';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { logError } from '../services/logging';
|
||||
import { getWatchMappings } from '../api/watch';
|
||||
|
||||
export function initWatcher(mainWindow: BrowserWindow) {
|
||||
const mappings = getWatchMappings();
|
||||
const folderPaths = mappings.map((mapping) => {
|
||||
return mapping.folderPath;
|
||||
});
|
||||
|
||||
const watcher = chokidar.watch(folderPaths, {
|
||||
awaitWriteFinish: true,
|
||||
});
|
||||
watcher
|
||||
.on('add', (path) => {
|
||||
mainWindow.webContents.send('watch-add', path);
|
||||
})
|
||||
.on('change', (path) => {
|
||||
mainWindow.webContents.send('watch-change', path);
|
||||
})
|
||||
.on('unlink', (path) => {
|
||||
mainWindow.webContents.send('watch-unlink', path);
|
||||
})
|
||||
.on('unlinkDir', (path) => {
|
||||
mainWindow.webContents.send('watch-unlink-dir', path);
|
||||
})
|
||||
.on('error', (error) => {
|
||||
logError(error, 'error while watching files');
|
||||
});
|
||||
|
||||
return watcher;
|
||||
}
|
469
desktop/src/services/clipService.ts
Normal file
|
@ -0,0 +1,469 @@
|
|||
import * as log from 'electron-log';
|
||||
import util from 'util';
|
||||
import { logErrorSentry } from './sentry';
|
||||
import { isDev } from '../utils/common';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
const shellescape = require('any-shell-escape');
|
||||
const execAsync = util.promisify(require('child_process').exec);
|
||||
import fetch from 'node-fetch';
|
||||
import { writeNodeStream } from './fs';
|
||||
import { getPlatform } from '../utils/common/platform';
|
||||
import { CustomErrors } from '../constants/errors';
|
||||
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');
|
||||
import Tokenizer from '../utils/clip-bpe-ts/mod';
|
||||
import { readFile } from 'promise-fs';
|
||||
import { Model } from '../types';
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
const MODEL_SAVE_FOLDER = 'models';
|
||||
|
||||
function getModelSavePath(modelName: string) {
|
||||
let userDataDir: string;
|
||||
if (isDev) {
|
||||
userDataDir = '.';
|
||||
} else {
|
||||
userDataDir = app.getPath('userData');
|
||||
}
|
||||
return path.join(userDataDir, MODEL_SAVE_FOLDER, modelName);
|
||||
}
|
||||
|
||||
async function downloadModel(saveLocation: string, url: string) {
|
||||
// confirm that the save location exists
|
||||
const saveDir = path.dirname(saveLocation);
|
||||
if (!existsSync(saveDir)) {
|
||||
log.info('creating model save dir');
|
||||
await fs.mkdir(saveDir, { recursive: true });
|
||||
}
|
||||
log.info('downloading clip model');
|
||||
const resp = await fetch(url);
|
||||
await writeNodeStream(saveLocation, resp.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 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 escapedCmd = shellescape(cmd);
|
||||
log.info('running clip command', escapedCmd);
|
||||
const startTime = Date.now();
|
||||
const { stdout } = await execAsync(escapedCmd);
|
||||
log.info('clip command execution time ', Date.now() - startTime);
|
||||
// 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) {
|
||||
logErrorSentry(err, 'Error in computeGGMLImageEmbedding');
|
||||
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) {
|
||||
logErrorSentry(err, 'Error in computeONNXImageEmbedding');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export 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 escapedCmd = shellescape(cmd);
|
||||
log.info('running clip command', escapedCmd);
|
||||
const startTime = Date.now();
|
||||
const { stdout } = await execAsync(escapedCmd);
|
||||
log.info('clip command execution time ', Date.now() - startTime);
|
||||
// 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 {
|
||||
logErrorSentry(err, 'Error in computeGGMLTextEmbedding');
|
||||
}
|
||||
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 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;
|
||||
};
|
98
desktop/src/services/diskCache.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import DiskLRUService from '../services/diskLRU';
|
||||
import crypto from 'crypto';
|
||||
import { existsSync, unlink, rename, stat } from 'promise-fs';
|
||||
import path from 'path';
|
||||
import { LimitedCache } from '../types/cache';
|
||||
import { logError } from './logging';
|
||||
import { getFileStream, writeStream } from './fs';
|
||||
|
||||
const DEFAULT_CACHE_LIMIT = 1000 * 1000 * 1000; // 1GB
|
||||
|
||||
export class DiskCache implements LimitedCache {
|
||||
constructor(
|
||||
private cacheBucketDir: string,
|
||||
private cacheLimit = DEFAULT_CACHE_LIMIT
|
||||
) {}
|
||||
|
||||
async put(cacheKey: string, response: Response): Promise<void> {
|
||||
const cachePath = path.join(this.cacheBucketDir, cacheKey);
|
||||
await writeStream(cachePath, response.body);
|
||||
DiskLRUService.enforceCacheSizeLimit(
|
||||
this.cacheBucketDir,
|
||||
this.cacheLimit
|
||||
);
|
||||
}
|
||||
|
||||
async match(
|
||||
cacheKey: string,
|
||||
{ sizeInBytes }: { sizeInBytes?: number } = {}
|
||||
): Promise<Response> {
|
||||
const cachePath = path.join(this.cacheBucketDir, cacheKey);
|
||||
if (existsSync(cachePath)) {
|
||||
const fileStats = await stat(cachePath);
|
||||
if (sizeInBytes && fileStats.size !== sizeInBytes) {
|
||||
logError(
|
||||
Error(),
|
||||
'Cache key exists but size does not match. Deleting cache key.'
|
||||
);
|
||||
unlink(cachePath).catch((e) => {
|
||||
if (e.code === 'ENOENT') return;
|
||||
logError(e, 'Failed to delete cache key');
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
DiskLRUService.touch(cachePath);
|
||||
return new Response(await getFileStream(cachePath));
|
||||
} else {
|
||||
// add fallback for old cache keys
|
||||
const oldCachePath = getOldAssetCachePath(
|
||||
this.cacheBucketDir,
|
||||
cacheKey
|
||||
);
|
||||
if (existsSync(oldCachePath)) {
|
||||
const fileStats = await stat(oldCachePath);
|
||||
if (sizeInBytes && fileStats.size !== sizeInBytes) {
|
||||
logError(
|
||||
Error(),
|
||||
'Old cache key exists but size does not match. Deleting cache key.'
|
||||
);
|
||||
unlink(oldCachePath).catch((e) => {
|
||||
if (e.code === 'ENOENT') return;
|
||||
logError(e, 'Failed to delete cache key');
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
const match = new Response(await getFileStream(oldCachePath));
|
||||
void migrateOldCacheKey(oldCachePath, cachePath);
|
||||
return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
async delete(cacheKey: string): Promise<boolean> {
|
||||
const cachePath = path.join(this.cacheBucketDir, cacheKey);
|
||||
if (existsSync(cachePath)) {
|
||||
await unlink(cachePath);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOldAssetCachePath(cacheDir: string, cacheKey: string) {
|
||||
// hashing the key to prevent illegal filenames
|
||||
const cacheKeyHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(cacheKey)
|
||||
.digest('hex');
|
||||
return path.join(cacheDir, cacheKeyHash);
|
||||
}
|
||||
|
||||
async function migrateOldCacheKey(oldCacheKey: string, newCacheKey: string) {
|
||||
try {
|
||||
await rename(oldCacheKey, newCacheKey);
|
||||
} catch (e) {
|
||||
logError(e, 'Failed to move cache key to new cache key');
|
||||
}
|
||||
}
|
106
desktop/src/services/diskLRU.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import path from 'path';
|
||||
import { readdir, stat, unlink } from 'promise-fs';
|
||||
import getFolderSize from 'get-folder-size';
|
||||
import { utimes, close, open } from 'promise-fs';
|
||||
import { logError } from '../services/logging';
|
||||
|
||||
export interface LeastRecentlyUsedResult {
|
||||
atime: Date;
|
||||
path: string;
|
||||
}
|
||||
|
||||
class DiskLRUService {
|
||||
private isRunning: Promise<any> = null;
|
||||
private reRun: boolean = false;
|
||||
|
||||
async touch(path: string) {
|
||||
try {
|
||||
const time = new Date();
|
||||
await utimes(path, time, time);
|
||||
} catch (err) {
|
||||
logError(err, 'utimes method touch failed');
|
||||
try {
|
||||
await close(await open(path, 'w'));
|
||||
} catch (e) {
|
||||
logError(e, 'open-close method touch failed');
|
||||
}
|
||||
// log and ignore
|
||||
}
|
||||
}
|
||||
|
||||
enforceCacheSizeLimit(cacheDir: string, maxSize: number) {
|
||||
if (!this.isRunning) {
|
||||
this.isRunning = this.evictLeastRecentlyUsed(cacheDir, maxSize);
|
||||
this.isRunning.then(() => {
|
||||
this.isRunning = null;
|
||||
if (this.reRun) {
|
||||
this.reRun = false;
|
||||
this.enforceCacheSizeLimit(cacheDir, maxSize);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.reRun = true;
|
||||
}
|
||||
}
|
||||
|
||||
async evictLeastRecentlyUsed(cacheDir: string, maxSize: number) {
|
||||
try {
|
||||
await new Promise((resolve) => {
|
||||
getFolderSize(cacheDir, async (err, size) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
if (size >= maxSize) {
|
||||
const leastRecentlyUsed =
|
||||
await this.findLeastRecentlyUsed(cacheDir);
|
||||
try {
|
||||
await unlink(leastRecentlyUsed.path);
|
||||
} catch (e) {
|
||||
// ENOENT: File not found
|
||||
// which can be ignored as we are trying to delete the file anyway
|
||||
if (e.code !== 'ENOENT') {
|
||||
logError(
|
||||
e,
|
||||
'Failed to evict least recently used'
|
||||
);
|
||||
}
|
||||
// ignoring the error, as it would get retried on the next run
|
||||
}
|
||||
this.evictLeastRecentlyUsed(cacheDir, maxSize);
|
||||
}
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e, 'evictLeastRecentlyUsed failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async findLeastRecentlyUsed(
|
||||
dir: string,
|
||||
result?: LeastRecentlyUsedResult
|
||||
): Promise<LeastRecentlyUsedResult> {
|
||||
result = result || { atime: new Date(), path: '' };
|
||||
|
||||
const files = await readdir(dir);
|
||||
for (const file of files) {
|
||||
const newBase = path.join(dir, file);
|
||||
const stats = await stat(newBase);
|
||||
if (stats.isDirectory()) {
|
||||
result = await this.findLeastRecentlyUsed(newBase, result);
|
||||
} else {
|
||||
const { atime } = await stat(newBase);
|
||||
|
||||
if (atime.getTime() < result.atime.getTime()) {
|
||||
result = {
|
||||
atime,
|
||||
path: newBase,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default new DiskLRUService();
|
94
desktop/src/services/ffmpeg.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import pathToFfmpeg from 'ffmpeg-static';
|
||||
const shellescape = require('any-shell-escape');
|
||||
import util from 'util';
|
||||
import log from 'electron-log';
|
||||
import { readFile, rmSync, writeFile } from 'promise-fs';
|
||||
import { logErrorSentry } from './sentry';
|
||||
import { generateTempFilePath, getTempDirPath } from '../utils/temp';
|
||||
import { existsSync } from 'fs';
|
||||
import { promiseWithTimeout } from '../utils/common';
|
||||
|
||||
const execAsync = util.promisify(require('child_process').exec);
|
||||
|
||||
const FFMPEG_EXECUTION_WAIT_TIME = 30 * 1000;
|
||||
|
||||
const INPUT_PATH_PLACEHOLDER = 'INPUT';
|
||||
const FFMPEG_PLACEHOLDER = 'FFMPEG';
|
||||
const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
|
||||
|
||||
function getFFmpegStaticPath() {
|
||||
return pathToFfmpeg.replace('app.asar', 'app.asar.unpacked');
|
||||
}
|
||||
|
||||
export async function runFFmpegCmd(
|
||||
cmd: string[],
|
||||
inputFilePath: string,
|
||||
outputFileName: string,
|
||||
dontTimeout = false
|
||||
) {
|
||||
let tempOutputFilePath: string;
|
||||
try {
|
||||
tempOutputFilePath = await generateTempFilePath(outputFileName);
|
||||
|
||||
cmd = cmd.map((cmdPart) => {
|
||||
if (cmdPart === FFMPEG_PLACEHOLDER) {
|
||||
return getFFmpegStaticPath();
|
||||
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
||||
return inputFilePath;
|
||||
} else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
|
||||
return tempOutputFilePath;
|
||||
} else {
|
||||
return cmdPart;
|
||||
}
|
||||
});
|
||||
const escapedCmd = shellescape(cmd);
|
||||
log.info('running ffmpeg command', escapedCmd);
|
||||
const startTime = Date.now();
|
||||
if (dontTimeout) {
|
||||
await execAsync(escapedCmd);
|
||||
} else {
|
||||
await promiseWithTimeout(
|
||||
execAsync(escapedCmd),
|
||||
FFMPEG_EXECUTION_WAIT_TIME
|
||||
);
|
||||
}
|
||||
if (!existsSync(tempOutputFilePath)) {
|
||||
throw new Error('ffmpeg output file not found');
|
||||
}
|
||||
log.info(
|
||||
'ffmpeg command execution time ',
|
||||
escapedCmd,
|
||||
Date.now() - startTime,
|
||||
'ms'
|
||||
);
|
||||
|
||||
const outputFile = await readFile(tempOutputFilePath);
|
||||
return new Uint8Array(outputFile);
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'ffmpeg run command error');
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
rmSync(tempOutputFilePath, { force: true });
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'failed to remove tempOutputFile');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTempFile(fileStream: Uint8Array, fileName: string) {
|
||||
const tempFilePath = await generateTempFilePath(fileName);
|
||||
await writeFile(tempFilePath, fileStream);
|
||||
return tempFilePath;
|
||||
}
|
||||
|
||||
export async function deleteTempFile(tempFilePath: string) {
|
||||
const tempDirPath = await getTempDirPath();
|
||||
if (!tempFilePath.startsWith(tempDirPath)) {
|
||||
logErrorSentry(
|
||||
Error('not a temp file'),
|
||||
'tried to delete a non temp file'
|
||||
);
|
||||
}
|
||||
rmSync(tempFilePath, { force: true });
|
||||
}
|
315
desktop/src/services/fs.ts
Normal file
|
@ -0,0 +1,315 @@
|
|||
import { FILE_STREAM_CHUNK_SIZE } from '../config';
|
||||
import path from 'path';
|
||||
import * as fs from 'promise-fs';
|
||||
import { ElectronFile } from '../types';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import { Readable } from 'stream';
|
||||
import { logError } from './logging';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
// https://stackoverflow.com/a/63111390
|
||||
export const getDirFilePaths = async (dirPath: string) => {
|
||||
if (!(await fs.stat(dirPath)).isDirectory()) {
|
||||
return [dirPath];
|
||||
}
|
||||
|
||||
let files: string[] = [];
|
||||
const filePaths = await fs.readdir(dirPath);
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const absolute = path.join(dirPath, filePath);
|
||||
files = [...files, ...(await getDirFilePaths(absolute))];
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
export const getFileStream = async (filePath: string) => {
|
||||
const file = await fs.open(filePath, 'r');
|
||||
let offset = 0;
|
||||
const readableStream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const buff = new Uint8Array(FILE_STREAM_CHUNK_SIZE);
|
||||
// original types were not working correctly
|
||||
const bytesRead = (await fs.read(
|
||||
file,
|
||||
buff,
|
||||
0,
|
||||
FILE_STREAM_CHUNK_SIZE,
|
||||
offset
|
||||
)) as unknown as number;
|
||||
offset += bytesRead;
|
||||
if (bytesRead === 0) {
|
||||
controller.close();
|
||||
await fs.close(file);
|
||||
} else {
|
||||
controller.enqueue(buff.slice(0, bytesRead));
|
||||
}
|
||||
} catch (e) {
|
||||
await fs.close(file);
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await fs.close(file);
|
||||
},
|
||||
});
|
||||
return readableStream;
|
||||
};
|
||||
|
||||
export async function getElectronFile(filePath: string): Promise<ElectronFile> {
|
||||
const fileStats = await fs.stat(filePath);
|
||||
return {
|
||||
path: filePath.split(path.sep).join(path.posix.sep),
|
||||
name: path.basename(filePath),
|
||||
size: fileStats.size,
|
||||
lastModified: fileStats.mtime.valueOf(),
|
||||
stream: async () => {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error('electronFile does not exist');
|
||||
}
|
||||
return await getFileStream(filePath);
|
||||
},
|
||||
blob: async () => {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error('electronFile does not exist');
|
||||
}
|
||||
const blob = await fs.readFile(filePath);
|
||||
return new Blob([new Uint8Array(blob)]);
|
||||
},
|
||||
arrayBuffer: async () => {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error('electronFile does not exist');
|
||||
}
|
||||
const blob = await fs.readFile(filePath);
|
||||
return new Uint8Array(blob);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const getValidPaths = (paths: string[]) => {
|
||||
if (!paths) {
|
||||
return [] as string[];
|
||||
}
|
||||
return paths.filter(async (path) => {
|
||||
try {
|
||||
await fs.stat(path).then((stat) => stat.isFile());
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getZipFileStream = async (
|
||||
zip: StreamZip.StreamZipAsync,
|
||||
filePath: string
|
||||
) => {
|
||||
const stream = await zip.stream(filePath);
|
||||
const done = {
|
||||
current: false,
|
||||
};
|
||||
const inProgress = {
|
||||
current: false,
|
||||
};
|
||||
let resolveObj: (value?: any) => void = null;
|
||||
let rejectObj: (reason?: any) => void = null;
|
||||
stream.on('readable', () => {
|
||||
try {
|
||||
if (resolveObj) {
|
||||
inProgress.current = true;
|
||||
const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
|
||||
if (chunk) {
|
||||
resolveObj(new Uint8Array(chunk));
|
||||
resolveObj = null;
|
||||
}
|
||||
inProgress.current = false;
|
||||
}
|
||||
} catch (e) {
|
||||
rejectObj(e);
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
try {
|
||||
done.current = true;
|
||||
if (resolveObj && !inProgress.current) {
|
||||
resolveObj(null);
|
||||
resolveObj = null;
|
||||
}
|
||||
} catch (e) {
|
||||
rejectObj(e);
|
||||
}
|
||||
});
|
||||
stream.on('error', (e) => {
|
||||
try {
|
||||
done.current = true;
|
||||
if (rejectObj) {
|
||||
rejectObj(e);
|
||||
rejectObj = null;
|
||||
}
|
||||
} catch (e) {
|
||||
rejectObj(e);
|
||||
}
|
||||
});
|
||||
|
||||
const readStreamData = async () => {
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
|
||||
|
||||
if (chunk || done.current) {
|
||||
resolve(chunk);
|
||||
} else {
|
||||
resolveObj = resolve;
|
||||
rejectObj = reject;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const readableStream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const data = await readStreamData();
|
||||
|
||||
if (data) {
|
||||
controller.enqueue(data);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
} catch (e) {
|
||||
logError(e, 'readableStream pull failed');
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return readableStream;
|
||||
};
|
||||
|
||||
export async function isFolder(dirPath: string) {
|
||||
try {
|
||||
const stats = await fs.stat(dirPath);
|
||||
return stats.isDirectory();
|
||||
} catch (e) {
|
||||
let err = e;
|
||||
// if code is defined, it's an error from fs.stat
|
||||
if (typeof e.code !== 'undefined') {
|
||||
// ENOENT means the file does not exist
|
||||
if (e.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
err = Error(`fs error code: ${e.code}`);
|
||||
}
|
||||
logError(err, 'isFolder failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const convertBrowserStreamToNode = (
|
||||
fileStream: ReadableStream<Uint8Array>
|
||||
) => {
|
||||
const reader = fileStream.getReader();
|
||||
const rs = new Readable();
|
||||
|
||||
rs._read = async () => {
|
||||
try {
|
||||
const result = await reader.read();
|
||||
|
||||
if (!result.done) {
|
||||
rs.push(Buffer.from(result.value));
|
||||
} else {
|
||||
rs.push(null);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
rs.emit('error', e);
|
||||
}
|
||||
};
|
||||
|
||||
return rs;
|
||||
};
|
||||
|
||||
export async function writeNodeStream(
|
||||
filePath: string,
|
||||
fileStream: NodeJS.ReadableStream
|
||||
) {
|
||||
const writeable = fs.createWriteStream(filePath);
|
||||
|
||||
fileStream.on('error', (error) => {
|
||||
writeable.destroy(error); // Close the writable stream with an error
|
||||
});
|
||||
|
||||
fileStream.pipe(writeable);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writeable.on('finish', resolve);
|
||||
writeable.on('error', async (e) => {
|
||||
if (existsSync(filePath)) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeStream(
|
||||
filePath: string,
|
||||
fileStream: ReadableStream<Uint8Array>
|
||||
) {
|
||||
const readable = convertBrowserStreamToNode(fileStream);
|
||||
await writeNodeStream(filePath, readable);
|
||||
}
|
||||
|
||||
export async function readTextFile(filePath: string) {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error('File does not exist');
|
||||
}
|
||||
return await fs.readFile(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
export async function moveFile(
|
||||
sourcePath: string,
|
||||
destinationPath: string
|
||||
): Promise<void> {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new Error('File does not exist');
|
||||
}
|
||||
if (existsSync(destinationPath)) {
|
||||
throw new Error('Destination file already exists');
|
||||
}
|
||||
// check if destination folder exists
|
||||
const destinationFolder = path.dirname(destinationPath);
|
||||
if (!existsSync(destinationFolder)) {
|
||||
await fs.mkdir(destinationFolder, { recursive: true });
|
||||
}
|
||||
await fs.rename(sourcePath, destinationPath);
|
||||
}
|
||||
|
||||
export async function deleteFolder(folderPath: string): Promise<void> {
|
||||
if (!existsSync(folderPath)) {
|
||||
return;
|
||||
}
|
||||
if (!fs.statSync(folderPath).isDirectory()) {
|
||||
throw new Error('Path is not a folder');
|
||||
}
|
||||
// check if folder is empty
|
||||
const files = await fs.readdir(folderPath);
|
||||
if (files.length > 0) {
|
||||
throw new Error('Folder is not empty');
|
||||
}
|
||||
await fs.rmdir(folderPath);
|
||||
}
|
||||
|
||||
export async function rename(oldPath: string, newPath: string) {
|
||||
if (!existsSync(oldPath)) {
|
||||
throw new Error('Path does not exist');
|
||||
}
|
||||
await fs.rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
export function deleteFile(filePath: string): void {
|
||||
if (!existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
if (!fs.statSync(filePath).isFile()) {
|
||||
throw new Error('Path is not a file');
|
||||
}
|
||||
fs.rmSync(filePath);
|
||||
}
|
280
desktop/src/services/imageProcessor.ts
Normal file
|
@ -0,0 +1,280 @@
|
|||
import util from 'util';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
import { existsSync, rmSync } from 'fs';
|
||||
import { readFile, writeFile } from 'promise-fs';
|
||||
import { generateTempFilePath } from '../utils/temp';
|
||||
import { logErrorSentry } from './sentry';
|
||||
import { isPlatform } from '../utils/common/platform';
|
||||
import { isDev } from '../utils/common';
|
||||
import path from 'path';
|
||||
import log from 'electron-log';
|
||||
import { CustomErrors } from '../constants/errors';
|
||||
const shellescape = require('any-shell-escape');
|
||||
|
||||
const asyncExec = util.promisify(exec);
|
||||
|
||||
const IMAGE_MAGICK_PLACEHOLDER = 'IMAGE_MAGICK';
|
||||
const MAX_DIMENSION_PLACEHOLDER = 'MAX_DIMENSION';
|
||||
const SAMPLE_SIZE_PLACEHOLDER = 'SAMPLE_SIZE';
|
||||
const INPUT_PATH_PLACEHOLDER = 'INPUT';
|
||||
const OUTPUT_PATH_PLACEHOLDER = 'OUTPUT';
|
||||
const QUALITY_PLACEHOLDER = 'QUALITY';
|
||||
|
||||
const MAX_QUALITY = 70;
|
||||
const MIN_QUALITY = 50;
|
||||
|
||||
const SIPS_HEIC_CONVERT_COMMAND_TEMPLATE = [
|
||||
'sips',
|
||||
'-s',
|
||||
'format',
|
||||
'jpeg',
|
||||
INPUT_PATH_PLACEHOLDER,
|
||||
'--out',
|
||||
OUTPUT_PATH_PLACEHOLDER,
|
||||
];
|
||||
|
||||
const SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
|
||||
'sips',
|
||||
'-s',
|
||||
'format',
|
||||
'jpeg',
|
||||
'-s',
|
||||
'formatOptions',
|
||||
QUALITY_PLACEHOLDER,
|
||||
'-Z',
|
||||
MAX_DIMENSION_PLACEHOLDER,
|
||||
INPUT_PATH_PLACEHOLDER,
|
||||
'--out',
|
||||
OUTPUT_PATH_PLACEHOLDER,
|
||||
];
|
||||
|
||||
const IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE = [
|
||||
IMAGE_MAGICK_PLACEHOLDER,
|
||||
INPUT_PATH_PLACEHOLDER,
|
||||
'-quality',
|
||||
'100%',
|
||||
OUTPUT_PATH_PLACEHOLDER,
|
||||
];
|
||||
|
||||
const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
|
||||
IMAGE_MAGICK_PLACEHOLDER,
|
||||
'-auto-orient',
|
||||
'-define',
|
||||
`jpeg:size=${SAMPLE_SIZE_PLACEHOLDER}x${SAMPLE_SIZE_PLACEHOLDER}`,
|
||||
INPUT_PATH_PLACEHOLDER,
|
||||
'-thumbnail',
|
||||
`${MAX_DIMENSION_PLACEHOLDER}x${MAX_DIMENSION_PLACEHOLDER}>`,
|
||||
'-unsharp',
|
||||
'0x.5',
|
||||
'-quality',
|
||||
QUALITY_PLACEHOLDER,
|
||||
OUTPUT_PATH_PLACEHOLDER,
|
||||
];
|
||||
|
||||
function getImageMagickStaticPath() {
|
||||
return isDev
|
||||
? 'build/image-magick'
|
||||
: path.join(process.resourcesPath, 'image-magick');
|
||||
}
|
||||
|
||||
export async function convertToJPEG(
|
||||
fileData: Uint8Array,
|
||||
filename: string
|
||||
): Promise<Uint8Array> {
|
||||
let tempInputFilePath: string;
|
||||
let tempOutputFilePath: string;
|
||||
try {
|
||||
tempInputFilePath = await generateTempFilePath(filename);
|
||||
tempOutputFilePath = await generateTempFilePath('output.jpeg');
|
||||
|
||||
await writeFile(tempInputFilePath, fileData);
|
||||
|
||||
await runConvertCommand(tempInputFilePath, tempOutputFilePath);
|
||||
|
||||
if (!existsSync(tempOutputFilePath)) {
|
||||
throw new Error('heic convert output file not found');
|
||||
}
|
||||
const convertedFileData = new Uint8Array(
|
||||
await readFile(tempOutputFilePath)
|
||||
);
|
||||
return convertedFileData;
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'failed to convert heic');
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
rmSync(tempInputFilePath, { force: true });
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'failed to remove tempInputFile');
|
||||
}
|
||||
try {
|
||||
rmSync(tempOutputFilePath, { force: true });
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'failed to remove tempOutputFile');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runConvertCommand(
|
||||
tempInputFilePath: string,
|
||||
tempOutputFilePath: string
|
||||
) {
|
||||
const convertCmd = constructConvertCommand(
|
||||
tempInputFilePath,
|
||||
tempOutputFilePath
|
||||
);
|
||||
const escapedCmd = shellescape(convertCmd);
|
||||
log.info('running convert command: ' + escapedCmd);
|
||||
await asyncExec(escapedCmd);
|
||||
}
|
||||
|
||||
function constructConvertCommand(
|
||||
tempInputFilePath: string,
|
||||
tempOutputFilePath: string
|
||||
) {
|
||||
let convertCmd: string[];
|
||||
if (isPlatform('mac')) {
|
||||
convertCmd = SIPS_HEIC_CONVERT_COMMAND_TEMPLATE.map((cmdPart) => {
|
||||
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
||||
return tempInputFilePath;
|
||||
}
|
||||
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
|
||||
return tempOutputFilePath;
|
||||
}
|
||||
return cmdPart;
|
||||
});
|
||||
} else if (isPlatform('linux')) {
|
||||
convertCmd = IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE.map(
|
||||
(cmdPart) => {
|
||||
if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
|
||||
return getImageMagickStaticPath();
|
||||
}
|
||||
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
||||
return tempInputFilePath;
|
||||
}
|
||||
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
|
||||
return tempOutputFilePath;
|
||||
}
|
||||
return cmdPart;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw Error(CustomErrors.INVALID_OS(process.platform));
|
||||
}
|
||||
return convertCmd;
|
||||
}
|
||||
|
||||
export async function generateImageThumbnail(
|
||||
inputFilePath: string,
|
||||
width: number,
|
||||
maxSize: number
|
||||
): Promise<Uint8Array> {
|
||||
let tempOutputFilePath: string;
|
||||
let quality = MAX_QUALITY;
|
||||
try {
|
||||
tempOutputFilePath = await generateTempFilePath('thumb.jpeg');
|
||||
let thumbnail: Uint8Array;
|
||||
do {
|
||||
await runThumbnailGenerationCommand(
|
||||
inputFilePath,
|
||||
tempOutputFilePath,
|
||||
width,
|
||||
quality
|
||||
);
|
||||
|
||||
if (!existsSync(tempOutputFilePath)) {
|
||||
throw new Error('output thumbnail file not found');
|
||||
}
|
||||
thumbnail = new Uint8Array(await readFile(tempOutputFilePath));
|
||||
quality -= 10;
|
||||
} while (thumbnail.length > maxSize && quality > MIN_QUALITY);
|
||||
return thumbnail;
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'generate image thumbnail failed');
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
rmSync(tempOutputFilePath, { force: true });
|
||||
} catch (e) {
|
||||
logErrorSentry(e, 'failed to remove tempOutputFile');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runThumbnailGenerationCommand(
|
||||
inputFilePath: string,
|
||||
tempOutputFilePath: string,
|
||||
maxDimension: number,
|
||||
quality: number
|
||||
) {
|
||||
const thumbnailGenerationCmd: string[] =
|
||||
constructThumbnailGenerationCommand(
|
||||
inputFilePath,
|
||||
tempOutputFilePath,
|
||||
maxDimension,
|
||||
quality
|
||||
);
|
||||
const escapedCmd = shellescape(thumbnailGenerationCmd);
|
||||
log.info('running thumbnail generation command: ' + escapedCmd);
|
||||
await asyncExec(escapedCmd);
|
||||
}
|
||||
function constructThumbnailGenerationCommand(
|
||||
inputFilePath: string,
|
||||
tempOutputFilePath: string,
|
||||
maxDimension: number,
|
||||
quality: number
|
||||
) {
|
||||
let thumbnailGenerationCmd: string[];
|
||||
if (isPlatform('mac')) {
|
||||
thumbnailGenerationCmd = SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map(
|
||||
(cmdPart) => {
|
||||
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
||||
return inputFilePath;
|
||||
}
|
||||
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
|
||||
return tempOutputFilePath;
|
||||
}
|
||||
if (cmdPart === MAX_DIMENSION_PLACEHOLDER) {
|
||||
return maxDimension.toString();
|
||||
}
|
||||
if (cmdPart === QUALITY_PLACEHOLDER) {
|
||||
return quality.toString();
|
||||
}
|
||||
return cmdPart;
|
||||
}
|
||||
);
|
||||
} else if (isPlatform('linux')) {
|
||||
thumbnailGenerationCmd =
|
||||
IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map((cmdPart) => {
|
||||
if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
|
||||
return getImageMagickStaticPath();
|
||||
}
|
||||
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
||||
return inputFilePath;
|
||||
}
|
||||
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
|
||||
return tempOutputFilePath;
|
||||
}
|
||||
if (cmdPart.includes(SAMPLE_SIZE_PLACEHOLDER)) {
|
||||
return cmdPart.replaceAll(
|
||||
SAMPLE_SIZE_PLACEHOLDER,
|
||||
(2 * maxDimension).toString()
|
||||
);
|
||||
}
|
||||
if (cmdPart.includes(MAX_DIMENSION_PLACEHOLDER)) {
|
||||
return cmdPart.replaceAll(
|
||||
MAX_DIMENSION_PLACEHOLDER,
|
||||
maxDimension.toString()
|
||||
);
|
||||
}
|
||||
if (cmdPart === QUALITY_PLACEHOLDER) {
|
||||
return quality.toString();
|
||||
}
|
||||
return cmdPart;
|
||||
});
|
||||
} else {
|
||||
throw Error(CustomErrors.INVALID_OS(process.platform));
|
||||
}
|
||||
return thumbnailGenerationCmd;
|
||||
}
|
22
desktop/src/services/logging.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import log from 'electron-log';
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
export function logToDisk(logLine: string) {
|
||||
log.info(logLine);
|
||||
}
|
||||
|
||||
export function openLogDirectory() {
|
||||
ipcRenderer.invoke('open-log-dir');
|
||||
}
|
||||
|
||||
export function logError(error: Error, message: string, info?: string): void {
|
||||
ipcRenderer.invoke('log-error', error, message, info);
|
||||
}
|
||||
|
||||
export function getSentryUserID(): Promise<string> {
|
||||
return ipcRenderer.invoke('get-sentry-id');
|
||||
}
|
||||
|
||||
export function updateOptOutOfCrashReports(optOut: boolean) {
|
||||
return ipcRenderer.invoke('update-opt-out-crash-reports', optOut);
|
||||
}
|
68
desktop/src/services/sentry.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import * as Sentry from '@sentry/electron/dist/main';
|
||||
import { makeID } from '../utils/logging';
|
||||
import { keysStore } from '../stores/keys.store';
|
||||
import { SENTRY_DSN, RELEASE_VERSION } from '../config';
|
||||
import { isDev } from '../utils/common';
|
||||
import { logToDisk } from './logging';
|
||||
import { hasOptedOutOfCrashReports } from '../main';
|
||||
|
||||
const ENV_DEVELOPMENT = 'development';
|
||||
|
||||
const isDEVSentryENV = () =>
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENV === ENV_DEVELOPMENT;
|
||||
|
||||
export function initSentry(): void {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
release: RELEASE_VERSION,
|
||||
environment: isDev ? 'development' : 'production',
|
||||
});
|
||||
Sentry.setUser({ id: getSentryUserID() });
|
||||
}
|
||||
|
||||
export function logErrorSentry(
|
||||
error: any,
|
||||
msg: string,
|
||||
info?: Record<string, unknown>
|
||||
) {
|
||||
const err = errorWithContext(error, msg);
|
||||
logToDisk(
|
||||
`error: ${error?.name} ${error?.message} ${
|
||||
error?.stack
|
||||
} msg: ${msg} info: ${JSON.stringify(info)}`
|
||||
);
|
||||
if (isDEVSentryENV()) {
|
||||
console.log(error, { msg, info });
|
||||
}
|
||||
if (hasOptedOutOfCrashReports()) {
|
||||
return;
|
||||
}
|
||||
Sentry.captureException(err, {
|
||||
level: Sentry.Severity.Info,
|
||||
user: { id: getSentryUserID() },
|
||||
contexts: {
|
||||
...(info && {
|
||||
info: info,
|
||||
}),
|
||||
rootCause: { message: error?.message },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function errorWithContext(originalError: Error, context: string) {
|
||||
const errorWithContext = new Error(context);
|
||||
errorWithContext.stack =
|
||||
errorWithContext.stack.split('\n').slice(2, 4).join('\n') +
|
||||
'\n' +
|
||||
originalError.stack;
|
||||
return errorWithContext;
|
||||
}
|
||||
|
||||
export function getSentryUserID() {
|
||||
let anonymizeUserID = keysStore.get('AnonymizeUserID')?.id;
|
||||
if (!anonymizeUserID) {
|
||||
anonymizeUserID = makeID(6);
|
||||
keysStore.set('AnonymizeUserID', { id: anonymizeUserID });
|
||||
}
|
||||
return anonymizeUserID;
|
||||
}
|
78
desktop/src/services/upload.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import StreamZip from 'node-stream-zip';
|
||||
import path from 'path';
|
||||
import { uploadStatusStore } from '../stores/upload.store';
|
||||
import { FILE_PATH_TYPE, FILE_PATH_KEYS, ElectronFile } from '../types';
|
||||
import { getValidPaths, getZipFileStream } from './fs';
|
||||
|
||||
export const getSavedFilePaths = (type: FILE_PATH_TYPE) => {
|
||||
const paths =
|
||||
getValidPaths(
|
||||
uploadStatusStore.get(FILE_PATH_KEYS[type]) as string[]
|
||||
) ?? [];
|
||||
|
||||
setToUploadFiles(type, paths);
|
||||
return paths;
|
||||
};
|
||||
|
||||
export async function getZipEntryAsElectronFile(
|
||||
zipName: string,
|
||||
zip: StreamZip.StreamZipAsync,
|
||||
entry: StreamZip.ZipEntry
|
||||
): Promise<ElectronFile> {
|
||||
return {
|
||||
path: path
|
||||
.join(zipName, entry.name)
|
||||
.split(path.sep)
|
||||
.join(path.posix.sep),
|
||||
name: path.basename(entry.name),
|
||||
size: entry.size,
|
||||
lastModified: entry.time,
|
||||
stream: async () => {
|
||||
return await getZipFileStream(zip, entry.name);
|
||||
},
|
||||
blob: async () => {
|
||||
const buffer = await zip.entryData(entry.name);
|
||||
return new Blob([new Uint8Array(buffer)]);
|
||||
},
|
||||
arrayBuffer: async () => {
|
||||
const buffer = await zip.entryData(entry.name);
|
||||
return new Uint8Array(buffer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const setToUploadFiles = (type: FILE_PATH_TYPE, filePaths: string[]) => {
|
||||
const key = FILE_PATH_KEYS[type];
|
||||
if (filePaths) {
|
||||
uploadStatusStore.set(key, filePaths);
|
||||
} else {
|
||||
uploadStatusStore.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const setToUploadCollection = (collectionName: string) => {
|
||||
if (collectionName) {
|
||||
uploadStatusStore.set('collectionName', collectionName);
|
||||
} else {
|
||||
uploadStatusStore.delete('collectionName');
|
||||
}
|
||||
};
|
||||
|
||||
export const getElectronFilesFromGoogleZip = async (filePath: string) => {
|
||||
const zip = new StreamZip.async({
|
||||
file: filePath,
|
||||
});
|
||||
const zipName = path.basename(filePath, '.zip');
|
||||
|
||||
const entries = await zip.entries();
|
||||
const files: ElectronFile[] = [];
|
||||
|
||||
for (const entry of Object.values(entries)) {
|
||||
const basename = path.basename(entry.name);
|
||||
if (entry.isFile && basename.length > 0 && basename[0] !== '.') {
|
||||
files.push(await getZipEntryAsElectronFile(zipName, zip, entry));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
49
desktop/src/services/userPreference.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
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 getOptOutOfCrashReports() {
|
||||
return userPreferencesStore.get('optOutOfCrashReports') ?? false;
|
||||
}
|
||||
|
||||
export function setOptOutOfCrashReports(optOut: boolean) {
|
||||
userPreferencesStore.set('optOutOfCrashReports', optOut);
|
||||
}
|
||||
|
||||
export function clearSkipAppVersion() {
|
||||
userPreferencesStore.delete('skipAppVersion');
|
||||
}
|
||||
|
||||
export function clearMuteUpdateNotificationVersion() {
|
||||
userPreferencesStore.delete('muteUpdateNotificationVersion');
|
||||
}
|
||||
|
||||
export function setCustomCacheDirectory(directory: string) {
|
||||
userPreferencesStore.set('customCacheDirectory', directory);
|
||||
}
|
||||
|
||||
export function getCustomCacheDirectory(): string {
|
||||
return userPreferencesStore.get('customCacheDirectory');
|
||||
}
|
11
desktop/src/services/watch.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { WatchStoreType } from '../types';
|
||||
import { watchStore } from '../stores/watch.store';
|
||||
|
||||
export function getWatchMappings() {
|
||||
const mappings = watchStore.get('mappings') ?? [];
|
||||
return mappings;
|
||||
}
|
||||
|
||||
export function setWatchMappings(watchMappings: WatchStoreType['mappings']) {
|
||||
watchStore.set('mappings', watchMappings);
|
||||
}
|
18
desktop/src/stores/keys.store.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import Store, { Schema } from 'electron-store';
|
||||
import { KeysStoreType } from '../types';
|
||||
|
||||
const keysStoreSchema: Schema<KeysStoreType> = {
|
||||
AnonymizeUserID: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const keysStore = new Store({
|
||||
name: 'keys',
|
||||
schema: keysStoreSchema,
|
||||
});
|
13
desktop/src/stores/safeStorage.store.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Store, { Schema } from 'electron-store';
|
||||
import { SafeStorageStoreType } from '../types';
|
||||
|
||||
const safeStorageSchema: Schema<SafeStorageStoreType> = {
|
||||
encryptionKey: {
|
||||
type: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
export const safeStorageStore = new Store({
|
||||
name: 'safeStorage',
|
||||
schema: safeStorageSchema,
|
||||
});
|
25
desktop/src/stores/upload.store.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import Store, { Schema } from 'electron-store';
|
||||
import { UploadStoreType } from '../types';
|
||||
|
||||
const uploadStoreSchema: Schema<UploadStoreType> = {
|
||||
filePaths: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
zipPaths: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
collectionName: {
|
||||
type: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
export const uploadStatusStore = new Store({
|
||||
name: 'upload-status',
|
||||
schema: uploadStoreSchema,
|
||||
});
|
25
desktop/src/stores/userPreferences.store.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import Store, { Schema } from 'electron-store';
|
||||
import { UserPreferencesType } from '../types';
|
||||
|
||||
const userPreferencesSchema: Schema<UserPreferencesType> = {
|
||||
hideDockIcon: {
|
||||
type: 'boolean',
|
||||
},
|
||||
skipAppVersion: {
|
||||
type: 'string',
|
||||
},
|
||||
muteUpdateNotificationVersion: {
|
||||
type: 'string',
|
||||
},
|
||||
optOutOfCrashReports: {
|
||||
type: 'boolean',
|
||||
},
|
||||
customCacheDirectory: {
|
||||
type: 'string',
|
||||
},
|
||||
};
|
||||
|
||||
export const userPreferencesStore = new Store({
|
||||
name: 'userPreferences',
|
||||
schema: userPreferencesSchema,
|
||||
});
|
47
desktop/src/stores/watch.store.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import Store, { Schema } from 'electron-store';
|
||||
import { WatchStoreType } from '../types';
|
||||
|
||||
const watchStoreSchema: Schema<WatchStoreType> = {
|
||||
mappings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rootFolderName: {
|
||||
type: 'string',
|
||||
},
|
||||
uploadStrategy: {
|
||||
type: 'number',
|
||||
},
|
||||
folderPath: {
|
||||
type: 'string',
|
||||
},
|
||||
syncedFiles: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
},
|
||||
id: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ignoredFiles: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const watchStore = new Store({
|
||||
name: 'watch-status',
|
||||
schema: watchStoreSchema,
|
||||
});
|
5
desktop/src/types/autoLauncher.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface AutoLauncherClient {
|
||||
isEnabled: () => Promise<boolean>;
|
||||
toggleAutoLaunch: () => Promise<void>;
|
||||
wasAutoLaunched: () => Promise<boolean>;
|
||||
}
|
8
desktop/src/types/cache.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export interface LimitedCache {
|
||||
match: (
|
||||
key: string,
|
||||
options?: { sizeInBytes?: number }
|
||||
) => Promise<Response>;
|
||||
put: (key: string, data: Response) => Promise<void>;
|
||||
delete: (key: string) => Promise<boolean>;
|
||||
}
|
77
desktop/src/types/index.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
export interface ElectronFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
stream: () => Promise<ReadableStream<Uint8Array>>;
|
||||
blob: () => Promise<Blob>;
|
||||
arrayBuffer: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface UploadStoreType {
|
||||
filePaths: string[];
|
||||
zipPaths: string[];
|
||||
collectionName: string;
|
||||
}
|
||||
|
||||
export interface KeysStoreType {
|
||||
AnonymizeUserID: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WatchMappingSyncedFile {
|
||||
path: string;
|
||||
uploadedFileID: number;
|
||||
collectionID: number;
|
||||
}
|
||||
|
||||
export interface WatchMapping {
|
||||
rootFolderName: string;
|
||||
uploadStrategy: number;
|
||||
folderPath: string;
|
||||
syncedFiles: WatchMappingSyncedFile[];
|
||||
ignoredFiles: string[];
|
||||
}
|
||||
|
||||
export interface WatchStoreType {
|
||||
mappings: WatchMapping[];
|
||||
}
|
||||
|
||||
export enum FILE_PATH_TYPE {
|
||||
FILES = 'files',
|
||||
ZIPS = 'zips',
|
||||
}
|
||||
|
||||
export const FILE_PATH_KEYS: {
|
||||
[k in FILE_PATH_TYPE]: keyof UploadStoreType;
|
||||
} = {
|
||||
[FILE_PATH_TYPE.ZIPS]: 'zipPaths',
|
||||
[FILE_PATH_TYPE.FILES]: 'filePaths',
|
||||
};
|
||||
|
||||
export interface SafeStorageStoreType {
|
||||
encryptionKey: string;
|
||||
}
|
||||
|
||||
export interface UserPreferencesType {
|
||||
hideDockIcon: boolean;
|
||||
skipAppVersion: string;
|
||||
muteUpdateNotificationVersion: string;
|
||||
optOutOfCrashReports: boolean;
|
||||
customCacheDirectory: string;
|
||||
}
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
autoUpdatable: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface GetFeatureFlagResponse {
|
||||
desktopCutoffVersion?: string;
|
||||
}
|
||||
|
||||
export enum Model {
|
||||
GGML_CLIP = 'ggml-clip',
|
||||
ONNX_CLIP = 'onnx-clip',
|
||||
}
|
21
desktop/src/utils/clip-bpe-ts/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 josephrocca
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
26
desktop/src/utils/clip-bpe-ts/README.md
Normal file
|
@ -0,0 +1,26 @@
|
|||
# CLIP Byte Pair Encoding JavaScript Port
|
||||
A JavaScript port of [OpenAI's CLIP byte-pair-encoding tokenizer](https://github.com/openai/CLIP/blob/3bee28119e6b28e75b82b811b87b56935314e6a5/clip/simple_tokenizer.py).
|
||||
|
||||
```js
|
||||
import Tokenizer from "https://deno.land/x/clip_bpe@v0.0.6/mod.js";
|
||||
let t = new Tokenizer();
|
||||
|
||||
t.encode("hello") // [3306]
|
||||
t.encode("magnificent") // [10724]
|
||||
t.encode("magnificently") // [9725, 2922]
|
||||
t.decode(t.encode("HELLO")) // "hello "
|
||||
t.decode(t.encode("abc123")) // "abc 1 2 3 "
|
||||
t.decode(st.encode("let's see here")) // "let 's see here "
|
||||
t.encode("hello world!") // [3306, 1002, 256]
|
||||
|
||||
// to encode for CLIP (trims to maximum of 77 tokens and adds start and end token, and pads with zeros if less than 77 tokens):
|
||||
t.encodeForCLIP("hello world!") // [49406,3306,1002,256,49407,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
|
||||
```
|
||||
|
||||
This encoder/decoder behaves differently to the the GPT-2/3 tokenizer (JavaScript version of that [here](https://github.com/latitudegames/GPT-3-Encoder)). For example, it doesn't preserve capital letters, as shown above.
|
||||
|
||||
The [Python version](https://github.com/openai/CLIP/blob/3bee28119e6b28e75b82b811b87b56935314e6a5/clip/simple_tokenizer.py) of this tokenizer uses the `ftfy` module to clean up the text before encoding it. I didn't include that module by default because currently the only version available in JavaScript is [this one](https://github.com/josephrocca/ftfy-pyodide), which requires importing a full Python runtime as a WebAssembly module. If you want the `ftfy` cleaning, just import it and clean your text with it before passing it to the `.encode()` method.
|
||||
|
||||
# License
|
||||
|
||||
To the extent that there is any original work in this repo, it is MIT Licensed, just like [openai/CLIP](https://github.com/openai/CLIP).
|
4
desktop/src/utils/clip-bpe-ts/bpe_simple_vocab_16e6.ts
Normal file
466
desktop/src/utils/clip-bpe-ts/mod.ts
Normal file
|
@ -0,0 +1,466 @@
|
|||
import * as htmlEntities from 'html-entities';
|
||||
import bpeVocabData from './bpe_simple_vocab_16e6';
|
||||
// import ftfy from "https://deno.land/x/ftfy_pyodide@v0.1.1/mod.js";
|
||||
|
||||
function ord(c: string) {
|
||||
return c.charCodeAt(0);
|
||||
}
|
||||
function range(start: number, stop?: number, step: number = 1) {
|
||||
if (stop === undefined) {
|
||||
stop = start;
|
||||
start = 0;
|
||||
}
|
||||
|
||||
if ((step > 0 && start >= stop) || (step < 0 && start <= stop)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: number[] = [];
|
||||
for (let i = start; step > 0 ? i < stop : i > stop; i += step) {
|
||||
result.push(i);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function bytesToUnicode() {
|
||||
const bs = [
|
||||
...range(ord('!'), ord('~') + 1),
|
||||
...range(ord('¡'), ord('¬') + 1),
|
||||
...range(ord('®'), ord('ÿ') + 1),
|
||||
];
|
||||
const cs = bs.slice(0);
|
||||
let n = 0;
|
||||
for (const b of range(2 ** 8)) {
|
||||
if (!bs.includes(b)) {
|
||||
bs.push(b);
|
||||
cs.push(2 ** 8 + n);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
const csString = cs.map((n) => String.fromCharCode(n));
|
||||
return Object.fromEntries(bs.map((v, i) => [v, csString[i]]));
|
||||
}
|
||||
|
||||
function getPairs(word: string | any[]) {
|
||||
const pairs: [string, string][] = [];
|
||||
let prevChar = word[0];
|
||||
for (const char of word.slice(1)) {
|
||||
pairs.push([prevChar, char]);
|
||||
prevChar = char;
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function basicClean(text: string) {
|
||||
// text = ftfy.fix_text(text);
|
||||
text = htmlEntities.decode(htmlEntities.decode(text));
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function whitespaceClean(text: string) {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export default class {
|
||||
byteEncoder;
|
||||
byteDecoder: {
|
||||
[k: string]: number;
|
||||
};
|
||||
encoder;
|
||||
decoder: any;
|
||||
bpeRanks: any;
|
||||
cache: Record<string, string>;
|
||||
pat: RegExp;
|
||||
constructor() {
|
||||
this.byteEncoder = bytesToUnicode();
|
||||
this.byteDecoder = Object.fromEntries(
|
||||
Object.entries(this.byteEncoder).map(([k, v]) => [v, Number(k)])
|
||||
);
|
||||
let merges = bpeVocabData.text.split('\n');
|
||||
merges = merges.slice(1, 49152 - 256 - 2 + 1);
|
||||
const mergedMerges = merges.map((merge) => merge.split(' '));
|
||||
// There was a bug related to the ordering of Python's .values() output. I'm lazy do I've just copy-pasted the Python output:
|
||||
let vocab = [
|
||||
'!',
|
||||
'"',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'&',
|
||||
"'",
|
||||
'(',
|
||||
')',
|
||||
'*',
|
||||
'+',
|
||||
',',
|
||||
'-',
|
||||
'.',
|
||||
'/',
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
':',
|
||||
';',
|
||||
'<',
|
||||
'=',
|
||||
'>',
|
||||
'?',
|
||||
'@',
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
'[',
|
||||
'\\',
|
||||
']',
|
||||
'^',
|
||||
'_',
|
||||
'`',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
'{',
|
||||
'|',
|
||||
'}',
|
||||
'~',
|
||||
'¡',
|
||||
'¢',
|
||||
'£',
|
||||
'¤',
|
||||
'¥',
|
||||
'¦',
|
||||
'§',
|
||||
'¨',
|
||||
'©',
|
||||
'ª',
|
||||
'«',
|
||||
'¬',
|
||||
'®',
|
||||
'¯',
|
||||
'°',
|
||||
'±',
|
||||
'²',
|
||||
'³',
|
||||
'´',
|
||||
'µ',
|
||||
'¶',
|
||||
'·',
|
||||
'¸',
|
||||
'¹',
|
||||
'º',
|
||||
'»',
|
||||
'¼',
|
||||
'½',
|
||||
'¾',
|
||||
'¿',
|
||||
'À',
|
||||
'Á',
|
||||
'Â',
|
||||
'Ã',
|
||||
'Ä',
|
||||
'Å',
|
||||
'Æ',
|
||||
'Ç',
|
||||
'È',
|
||||
'É',
|
||||
'Ê',
|
||||
'Ë',
|
||||
'Ì',
|
||||
'Í',
|
||||
'Î',
|
||||
'Ï',
|
||||
'Ð',
|
||||
'Ñ',
|
||||
'Ò',
|
||||
'Ó',
|
||||
'Ô',
|
||||
'Õ',
|
||||
'Ö',
|
||||
'×',
|
||||
'Ø',
|
||||
'Ù',
|
||||
'Ú',
|
||||
'Û',
|
||||
'Ü',
|
||||
'Ý',
|
||||
'Þ',
|
||||
'ß',
|
||||
'à',
|
||||
'á',
|
||||
'â',
|
||||
'ã',
|
||||
'ä',
|
||||
'å',
|
||||
'æ',
|
||||
'ç',
|
||||
'è',
|
||||
'é',
|
||||
'ê',
|
||||
'ë',
|
||||
'ì',
|
||||
'í',
|
||||
'î',
|
||||
'ï',
|
||||
'ð',
|
||||
'ñ',
|
||||
'ò',
|
||||
'ó',
|
||||
'ô',
|
||||
'õ',
|
||||
'ö',
|
||||
'÷',
|
||||
'ø',
|
||||
'ù',
|
||||
'ú',
|
||||
'û',
|
||||
'ü',
|
||||
'ý',
|
||||
'þ',
|
||||
'ÿ',
|
||||
'Ā',
|
||||
'ā',
|
||||
'Ă',
|
||||
'ă',
|
||||
'Ą',
|
||||
'ą',
|
||||
'Ć',
|
||||
'ć',
|
||||
'Ĉ',
|
||||
'ĉ',
|
||||
'Ċ',
|
||||
'ċ',
|
||||
'Č',
|
||||
'č',
|
||||
'Ď',
|
||||
'ď',
|
||||
'Đ',
|
||||
'đ',
|
||||
'Ē',
|
||||
'ē',
|
||||
'Ĕ',
|
||||
'ĕ',
|
||||
'Ė',
|
||||
'ė',
|
||||
'Ę',
|
||||
'ę',
|
||||
'Ě',
|
||||
'ě',
|
||||
'Ĝ',
|
||||
'ĝ',
|
||||
'Ğ',
|
||||
'ğ',
|
||||
'Ġ',
|
||||
'ġ',
|
||||
'Ģ',
|
||||
'ģ',
|
||||
'Ĥ',
|
||||
'ĥ',
|
||||
'Ħ',
|
||||
'ħ',
|
||||
'Ĩ',
|
||||
'ĩ',
|
||||
'Ī',
|
||||
'ī',
|
||||
'Ĭ',
|
||||
'ĭ',
|
||||
'Į',
|
||||
'į',
|
||||
'İ',
|
||||
'ı',
|
||||
'IJ',
|
||||
'ij',
|
||||
'Ĵ',
|
||||
'ĵ',
|
||||
'Ķ',
|
||||
'ķ',
|
||||
'ĸ',
|
||||
'Ĺ',
|
||||
'ĺ',
|
||||
'Ļ',
|
||||
'ļ',
|
||||
'Ľ',
|
||||
'ľ',
|
||||
'Ŀ',
|
||||
'ŀ',
|
||||
'Ł',
|
||||
'ł',
|
||||
'Ń',
|
||||
];
|
||||
vocab = [...vocab, ...vocab.map((v) => v + '</w>')];
|
||||
for (const merge of mergedMerges) {
|
||||
vocab.push(merge.join(''));
|
||||
}
|
||||
vocab.push('<|startoftext|>', '<|endoftext|>');
|
||||
this.encoder = Object.fromEntries(vocab.map((v, i) => [v, i]));
|
||||
this.decoder = Object.fromEntries(
|
||||
Object.entries(this.encoder).map(([k, v]) => [v, k])
|
||||
);
|
||||
this.bpeRanks = Object.fromEntries(
|
||||
mergedMerges.map((v, i) => [v.join('·😎·'), i])
|
||||
); // ·😎· because js doesn't yet have tuples
|
||||
this.cache = {
|
||||
'<|startoftext|>': '<|startoftext|>',
|
||||
'<|endoftext|>': '<|endoftext|>',
|
||||
};
|
||||
this.pat =
|
||||
/<\|startoftext\|>|<\|endoftext\|>|'s|'t|'re|'ve|'m|'ll|'d|[\p{L}]+|[\p{N}]|[^\s\p{L}\p{N}]+/giu;
|
||||
}
|
||||
|
||||
bpe(token: string) {
|
||||
if (this.cache[token] !== undefined) {
|
||||
return this.cache[token];
|
||||
}
|
||||
|
||||
let word = [...token.slice(0, -1), token.slice(-1) + '</w>'];
|
||||
let pairs = getPairs(word);
|
||||
|
||||
if (pairs.length === 0) {
|
||||
return token + '</w>';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (1) {
|
||||
let bigram: [string, string] | null = null;
|
||||
let minRank = Infinity;
|
||||
for (const p of pairs) {
|
||||
const r = this.bpeRanks[p.join('·😎·')];
|
||||
if (r === undefined) continue;
|
||||
if (r < minRank) {
|
||||
minRank = r;
|
||||
bigram = p;
|
||||
}
|
||||
}
|
||||
|
||||
if (bigram === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
const [first, second] = bigram;
|
||||
const newWord: string[] = [];
|
||||
let i = 0;
|
||||
while (i < word.length) {
|
||||
const j = word.indexOf(first, i);
|
||||
|
||||
if (j === -1) {
|
||||
newWord.push(...word.slice(i));
|
||||
break;
|
||||
}
|
||||
|
||||
newWord.push(...word.slice(i, j));
|
||||
i = j;
|
||||
|
||||
if (
|
||||
word[i] === first &&
|
||||
i < word.length - 1 &&
|
||||
word[i + 1] === second
|
||||
) {
|
||||
newWord.push(first + second);
|
||||
i += 2;
|
||||
} else {
|
||||
newWord.push(word[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
word = newWord;
|
||||
if (word.length === 1) {
|
||||
break;
|
||||
} else {
|
||||
pairs = getPairs(word);
|
||||
}
|
||||
}
|
||||
const joinedWord = word.join(' ');
|
||||
this.cache[token] = joinedWord;
|
||||
return joinedWord;
|
||||
}
|
||||
|
||||
encode(text: string) {
|
||||
const bpeTokens: number[] = [];
|
||||
text = whitespaceClean(basicClean(text)).toLowerCase();
|
||||
for (let token of [...text.matchAll(this.pat)].map((m) => m[0])) {
|
||||
token = [...token]
|
||||
.map((b) => this.byteEncoder[b.charCodeAt(0) as number])
|
||||
.join('');
|
||||
bpeTokens.push(
|
||||
...this.bpe(token)
|
||||
.split(' ')
|
||||
.map((bpeToken: string) => this.encoder[bpeToken])
|
||||
);
|
||||
}
|
||||
return bpeTokens;
|
||||
}
|
||||
|
||||
// adds start and end token, and adds padding 0's and ensures it's 77 tokens long
|
||||
encodeForCLIP(text: string) {
|
||||
let tokens = this.encode(text);
|
||||
tokens.unshift(49406); // start token
|
||||
tokens = tokens.slice(0, 76);
|
||||
tokens.push(49407); // end token
|
||||
while (tokens.length < 77) tokens.push(0);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
decode(tokens: any[]) {
|
||||
let text = tokens
|
||||
.map((token: string | number) => this.decoder[token])
|
||||
.join('');
|
||||
text = [...text]
|
||||
.map((c) => this.byteDecoder[c])
|
||||
.map((v) => String.fromCharCode(v))
|
||||
.join('')
|
||||
.replace(/<\/w>/g, ' ');
|
||||
return text;
|
||||
}
|
||||
}
|
27
desktop/src/utils/common/index.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { CustomErrors } from '../../constants/errors';
|
||||
import { app } from 'electron';
|
||||
export const isDev = !app.isPackaged;
|
||||
|
||||
export const promiseWithTimeout = async <T>(
|
||||
request: Promise<T>,
|
||||
timeout: number
|
||||
): Promise<T> => {
|
||||
const timeoutRef: {
|
||||
current: NodeJS.Timeout;
|
||||
} = { current: null };
|
||||
const rejectOnTimeout = new Promise<null>((_, reject) => {
|
||||
timeoutRef.current = setTimeout(
|
||||
() => reject(Error(CustomErrors.WAIT_TIME_EXCEEDED)),
|
||||
timeout
|
||||
);
|
||||
});
|
||||
const requestWithTimeOutCancellation = async () => {
|
||||
const resp = await request;
|
||||
clearTimeout(timeoutRef.current);
|
||||
return resp;
|
||||
};
|
||||
return await Promise.race([
|
||||
requestWithTimeOutCancellation(),
|
||||
rejectOnTimeout,
|
||||
]);
|
||||
};
|
19
desktop/src/utils/common/platform.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export function isPlatform(platform: 'mac' | 'windows' | 'linux') {
|
||||
return getPlatform() === platform;
|
||||
}
|
||||
|
||||
export function getPlatform(): 'mac' | 'windows' | 'linux' {
|
||||
switch (process.platform) {
|
||||
case 'aix':
|
||||
case 'freebsd':
|
||||
case 'linux':
|
||||
case 'openbsd':
|
||||
case 'android':
|
||||
return 'linux';
|
||||
case 'darwin':
|
||||
case 'sunos':
|
||||
return 'mac';
|
||||
case 'win32':
|
||||
return 'windows';
|
||||
}
|
||||
}
|
21
desktop/src/utils/cors.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
|
||||
function lowerCaseHeaders(responseHeaders: Record<string, string[]>) {
|
||||
const headers: Record<string, string[]> = {};
|
||||
for (const key of Object.keys(responseHeaders)) {
|
||||
headers[key.toLowerCase()] = responseHeaders[key];
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function addAllowOriginHeader(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
details.responseHeaders = lowerCaseHeaders(details.responseHeaders);
|
||||
details.responseHeaders['access-control-allow-origin'] = ['*'];
|
||||
callback({
|
||||
responseHeaders: details.responseHeaders,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
118
desktop/src/utils/createWindow.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import { app, BrowserWindow, nativeImage } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { isDev } from './common';
|
||||
import { isAppQuitting } from '../main';
|
||||
import { PROD_HOST_URL } from '../config';
|
||||
import { isPlatform } from './common/platform';
|
||||
import { getHideDockIconPreference } from '../services/userPreference';
|
||||
import autoLauncher from '../services/autoLauncher';
|
||||
import ElectronLog from 'electron-log';
|
||||
import { logErrorSentry } from '../services/sentry';
|
||||
|
||||
export async function createWindow(): Promise<BrowserWindow> {
|
||||
const appImgPath = isDev
|
||||
? 'build/window-icon.png'
|
||||
: path.join(process.resourcesPath, 'window-icon.png');
|
||||
const appIcon = nativeImage.createFromPath(appImgPath);
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
webPreferences: {
|
||||
sandbox: false,
|
||||
preload: path.join(__dirname, '../preload.js'),
|
||||
contextIsolation: false,
|
||||
},
|
||||
icon: appIcon,
|
||||
show: false, // don't show the main window on load,
|
||||
});
|
||||
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
|
||||
ElectronLog.log('wasAutoLaunched', wasAutoLaunched);
|
||||
|
||||
const splash = new BrowserWindow({
|
||||
transparent: true,
|
||||
show: false,
|
||||
});
|
||||
if (isPlatform('mac') && wasAutoLaunched) {
|
||||
app.dock.hide();
|
||||
}
|
||||
if (!wasAutoLaunched) {
|
||||
splash.maximize();
|
||||
splash.show();
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
splash.loadFile(`../build/splash.html`);
|
||||
mainWindow.loadURL(PROD_HOST_URL);
|
||||
// Open the DevTools.
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
splash.loadURL(
|
||||
`file://${path.join(process.resourcesPath, 'splash.html')}`
|
||||
);
|
||||
mainWindow.loadURL(PROD_HOST_URL);
|
||||
}
|
||||
mainWindow.webContents.on('did-fail-load', () => {
|
||||
splash.close();
|
||||
isDev
|
||||
? mainWindow.loadFile(`../../build/error.html`)
|
||||
: splash.loadURL(
|
||||
`file://${path.join(process.resourcesPath, 'error.html')}`
|
||||
);
|
||||
mainWindow.maximize();
|
||||
mainWindow.show();
|
||||
});
|
||||
mainWindow.once('ready-to-show', async () => {
|
||||
try {
|
||||
splash.destroy();
|
||||
if (!wasAutoLaunched) {
|
||||
mainWindow.maximize();
|
||||
mainWindow.show();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
mainWindow.webContents.on('render-process-gone', (event, details) => {
|
||||
mainWindow.webContents.reload();
|
||||
logErrorSentry(
|
||||
Error('render-process-gone'),
|
||||
'webContents event render-process-gone',
|
||||
{ details }
|
||||
);
|
||||
ElectronLog.log('webContents event render-process-gone', details);
|
||||
});
|
||||
mainWindow.webContents.on('unresponsive', () => {
|
||||
mainWindow.webContents.forcefullyCrashRenderer();
|
||||
ElectronLog.log('webContents event unresponsive');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
splash.destroy();
|
||||
if (!wasAutoLaunched) {
|
||||
mainWindow.maximize();
|
||||
mainWindow.show();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, 2000);
|
||||
mainWindow.on('close', function (event) {
|
||||
if (!isAppQuitting()) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
mainWindow.on('hide', () => {
|
||||
const shouldHideDockIcon = getHideDockIconPreference();
|
||||
if (isPlatform('mac') && shouldHideDockIcon) {
|
||||
app.dock.hide();
|
||||
}
|
||||
});
|
||||
mainWindow.on('show', () => {
|
||||
if (isPlatform('mac')) {
|
||||
app.dock.show();
|
||||
}
|
||||
});
|
||||
return mainWindow;
|
||||
}
|
17
desktop/src/utils/error.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { CustomErrors } from '../constants/errors';
|
||||
|
||||
export const isExecError = (err: any) => {
|
||||
return err.message.includes('Command failed:');
|
||||
};
|
||||
|
||||
export const parseExecError = (err: any) => {
|
||||
const errMessage = err.message;
|
||||
if (errMessage.includes('Bad CPU type in executable')) {
|
||||
return CustomErrors.UNSUPPORTED_PLATFORM(
|
||||
process.platform,
|
||||
process.arch
|
||||
);
|
||||
} else {
|
||||
return errMessage;
|
||||
}
|
||||
};
|
8
desktop/src/utils/events.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
|
||||
export function setupAppEventEmitter(mainWindow: BrowserWindow) {
|
||||
// fire event when mainWindow is in foreground
|
||||
mainWindow.on('focus', () => {
|
||||
mainWindow.webContents.send('app-in-foreground');
|
||||
});
|
||||
}
|
203
desktop/src/utils/ipcComms.ts
Normal file
|
@ -0,0 +1,203 @@
|
|||
import {
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Tray,
|
||||
Notification,
|
||||
safeStorage,
|
||||
app,
|
||||
shell,
|
||||
} from 'electron';
|
||||
import { createWindow } from './createWindow';
|
||||
import { getSentryUserID, logErrorSentry } from '../services/sentry';
|
||||
import chokidar from 'chokidar';
|
||||
import path from 'path';
|
||||
import { getDirFilePaths } from '../services/fs';
|
||||
import {
|
||||
convertToJPEG,
|
||||
generateImageThumbnail,
|
||||
} from '../services/imageProcessor';
|
||||
import {
|
||||
getAppVersion,
|
||||
muteUpdateNotification,
|
||||
skipAppUpdate,
|
||||
updateAndRestart,
|
||||
} from '../services/appUpdater';
|
||||
import { deleteTempFile, runFFmpegCmd } from '../services/ffmpeg';
|
||||
import { generateTempFilePath } from './temp';
|
||||
import {
|
||||
getCustomCacheDirectory,
|
||||
setCustomCacheDirectory,
|
||||
setOptOutOfCrashReports,
|
||||
} from '../services/userPreference';
|
||||
import { updateOptOutOfCrashReports } from '../main';
|
||||
import {
|
||||
computeImageEmbedding,
|
||||
computeTextEmbedding,
|
||||
} from '../services/clipService';
|
||||
import { getPlatform } from './common/platform';
|
||||
|
||||
export default function setupIpcComs(
|
||||
tray: Tray,
|
||||
mainWindow: BrowserWindow,
|
||||
watcher: chokidar.FSWatcher
|
||||
): void {
|
||||
ipcMain.handle('select-dir', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
if (result.filePaths && result.filePaths.length > 0) {
|
||||
return result.filePaths[0]?.split(path.sep)?.join(path.posix.sep);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('send-notification', (_, args) => {
|
||||
const notification = {
|
||||
title: 'ente',
|
||||
body: args,
|
||||
};
|
||||
new Notification(notification).show();
|
||||
});
|
||||
ipcMain.on('reload-window', async () => {
|
||||
const secondWindow = await createWindow();
|
||||
mainWindow.destroy();
|
||||
mainWindow = secondWindow;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-upload-files-dialog', async () => {
|
||||
const files = await dialog.showOpenDialog({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
});
|
||||
return files.filePaths;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-upload-zip-dialog', async () => {
|
||||
const files = await dialog.showOpenDialog({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [{ name: 'Zip File', extensions: ['zip'] }],
|
||||
});
|
||||
return files.filePaths;
|
||||
});
|
||||
|
||||
ipcMain.handle('show-upload-dirs-dialog', async () => {
|
||||
const dir = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory', 'multiSelections'],
|
||||
});
|
||||
|
||||
let files: string[] = [];
|
||||
for (const dirPath of dir.filePaths) {
|
||||
files = [...files, ...(await getDirFilePaths(dirPath))];
|
||||
}
|
||||
|
||||
return files;
|
||||
});
|
||||
|
||||
ipcMain.handle('add-watcher', async (_, args: { dir: string }) => {
|
||||
watcher.add(args.dir);
|
||||
});
|
||||
|
||||
ipcMain.handle('remove-watcher', async (_, args: { dir: string }) => {
|
||||
watcher.unwatch(args.dir);
|
||||
});
|
||||
|
||||
ipcMain.handle('log-error', (_, err, msg, info?) => {
|
||||
logErrorSentry(err, msg, info);
|
||||
});
|
||||
|
||||
ipcMain.handle('safeStorage-encrypt', (_, message) => {
|
||||
return safeStorage.encryptString(message);
|
||||
});
|
||||
|
||||
ipcMain.handle('safeStorage-decrypt', (_, message) => {
|
||||
return safeStorage.decryptString(message);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-path', (_, message) => {
|
||||
// By default, these paths are at the following locations:
|
||||
//
|
||||
// * macOS: `~/Library/Application Support/ente`
|
||||
// * Linux: `~/.config/ente`
|
||||
// * Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local\ente`
|
||||
// * Windows: C:\Users\<you>\AppData\Local\<Your App Name>
|
||||
//
|
||||
// https://www.electronjs.org/docs/latest/api/app
|
||||
return app.getPath(message);
|
||||
});
|
||||
|
||||
ipcMain.handle('convert-to-jpeg', (_, fileData, filename) => {
|
||||
return convertToJPEG(fileData, filename);
|
||||
});
|
||||
|
||||
ipcMain.handle('open-log-dir', () => {
|
||||
shell.openPath(app.getPath('logs'));
|
||||
});
|
||||
|
||||
ipcMain.handle('open-dir', (_, dirPath) => {
|
||||
shell.openPath(path.normalize(dirPath));
|
||||
});
|
||||
|
||||
ipcMain.on('update-and-restart', () => {
|
||||
updateAndRestart();
|
||||
});
|
||||
ipcMain.on('skip-app-update', (_, version) => {
|
||||
skipAppUpdate(version);
|
||||
});
|
||||
|
||||
ipcMain.on('mute-update-notification', (_, version) => {
|
||||
muteUpdateNotification(version);
|
||||
});
|
||||
ipcMain.handle('get-sentry-id', () => {
|
||||
return getSentryUserID();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-app-version', () => {
|
||||
return getAppVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'run-ffmpeg-cmd',
|
||||
(_, cmd, inputFilePath, outputFileName, dontTimeout) => {
|
||||
return runFFmpegCmd(
|
||||
cmd,
|
||||
inputFilePath,
|
||||
outputFileName,
|
||||
dontTimeout
|
||||
);
|
||||
}
|
||||
);
|
||||
ipcMain.handle('get-temp-file-path', (_, formatSuffix) => {
|
||||
return generateTempFilePath(formatSuffix);
|
||||
});
|
||||
ipcMain.handle('remove-temp-file', (_, tempFilePath: string) => {
|
||||
return deleteTempFile(tempFilePath);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'generate-image-thumbnail',
|
||||
(_, fileData, maxDimension, maxSize) => {
|
||||
return generateImageThumbnail(fileData, maxDimension, maxSize);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle('update-opt-out-crash-reports', (_, optOut) => {
|
||||
setOptOutOfCrashReports(optOut);
|
||||
updateOptOutOfCrashReports(optOut);
|
||||
});
|
||||
ipcMain.handle('compute-image-embedding', (_, model, inputFilePath) => {
|
||||
return computeImageEmbedding(model, inputFilePath);
|
||||
});
|
||||
ipcMain.handle('compute-text-embedding', (_, model, text) => {
|
||||
return computeTextEmbedding(model, text);
|
||||
});
|
||||
ipcMain.handle('get-platform', () => {
|
||||
return getPlatform();
|
||||
});
|
||||
|
||||
ipcMain.handle('set-custom-cache-directory', (_, directory: string) => {
|
||||
setCustomCacheDirectory(directory);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-custom-cache-directory', async () => {
|
||||
return getCustomCacheDirectory();
|
||||
});
|
||||
}
|
38
desktop/src/utils/logging.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import log from 'electron-log';
|
||||
import { LOG_FILENAME, MAX_LOG_SIZE } from '../config';
|
||||
|
||||
export function setupLogging(isDev?: boolean) {
|
||||
log.transports.file.fileName = LOG_FILENAME;
|
||||
log.transports.file.maxSize = MAX_LOG_SIZE;
|
||||
if (!isDev) {
|
||||
log.transports.console.level = false;
|
||||
}
|
||||
log.transports.file.format =
|
||||
'[{y}-{m}-{d}T{h}:{i}:{s}{z}] [{level}]{scope} {text}';
|
||||
}
|
||||
|
||||
export function makeID(length: number) {
|
||||
let result = '';
|
||||
const characters =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(
|
||||
Math.floor(Math.random() * charactersLength)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertBytesToHumanReadable(
|
||||
bytes: number,
|
||||
precision = 2
|
||||
): string {
|
||||
if (bytes === 0 || isNaN(bytes)) {
|
||||
return '0 MB';
|
||||
}
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
return (bytes / Math.pow(1024, i)).toFixed(precision) + ' ' + sizes[i];
|
||||
}
|
136
desktop/src/utils/main.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { PROD_HOST_URL, RENDERER_OUTPUT_DIR } from '../config';
|
||||
import { nativeImage, Tray, app, BrowserWindow, Menu } from 'electron';
|
||||
import electronReload from 'electron-reload';
|
||||
import serveNextAt from 'next-electron-server';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'promise-fs';
|
||||
import { isDev } from './common';
|
||||
import { buildContextMenu, buildMenuBar } from './menu';
|
||||
import autoLauncher from '../services/autoLauncher';
|
||||
import { getHideDockIconPreference } from '../services/userPreference';
|
||||
import { setupAutoUpdater } from '../services/appUpdater';
|
||||
import ElectronLog from 'electron-log';
|
||||
import os from 'os';
|
||||
import util from 'util';
|
||||
import { isPlatform } from './common/platform';
|
||||
const execAsync = util.promisify(require('child_process').exec);
|
||||
|
||||
export async function handleUpdates(mainWindow: BrowserWindow) {
|
||||
const isInstalledViaBrew = await checkIfInstalledViaBrew();
|
||||
if (!isDev && !isInstalledViaBrew) {
|
||||
setupAutoUpdater(mainWindow);
|
||||
}
|
||||
}
|
||||
export function 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(buildContextMenu(mainWindow));
|
||||
return tray;
|
||||
}
|
||||
|
||||
export function handleDownloads(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.session.on('will-download', (_, item) => {
|
||||
item.setSavePath(
|
||||
getUniqueSavePath(item.getFilename(), app.getPath('downloads'))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getUniqueSavePath(filename: string, directory: string): string {
|
||||
let uniqueFileSavePath = path.join(directory, filename);
|
||||
const { name: filenameWithoutExtension, ext: extension } =
|
||||
path.parse(filename);
|
||||
let n = 0;
|
||||
while (existsSync(uniqueFileSavePath)) {
|
||||
n++;
|
||||
// filter need to remove undefined extension from the array
|
||||
// else [`${fileName}`, undefined].join(".") will lead to `${fileName}.` as joined string
|
||||
const fileNameWithNumberedSuffix = [
|
||||
`${filenameWithoutExtension}(${n})`,
|
||||
extension,
|
||||
]
|
||||
.filter((x) => x) // filters out undefined/null values
|
||||
.join('');
|
||||
uniqueFileSavePath = path.join(directory, fileNameWithNumberedSuffix);
|
||||
}
|
||||
return uniqueFileSavePath;
|
||||
}
|
||||
|
||||
export function setupMacWindowOnDockIconClick() {
|
||||
app.on('activate', function () {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
// we allow only one window
|
||||
windows[0].show();
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupMainMenu(mainWindow: BrowserWindow) {
|
||||
Menu.setApplicationMenu(await buildMenuBar(mainWindow));
|
||||
}
|
||||
|
||||
export function setupMainHotReload() {
|
||||
if (isDev) {
|
||||
electronReload(__dirname, {});
|
||||
}
|
||||
}
|
||||
|
||||
export function setupNextElectronServe() {
|
||||
serveNextAt(PROD_HOST_URL, {
|
||||
outputDir: RENDERER_OUTPUT_DIR,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDockIconHideOnAutoLaunch() {
|
||||
const shouldHideDockIcon = getHideDockIconPreference();
|
||||
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
|
||||
|
||||
if (isPlatform('mac') && shouldHideDockIcon && wasAutoLaunched) {
|
||||
app.dock.hide();
|
||||
}
|
||||
}
|
||||
|
||||
export function enableSharedArrayBufferSupport() {
|
||||
app.commandLine.appendSwitch('enable-features', 'SharedArrayBuffer');
|
||||
}
|
||||
|
||||
export function logSystemInfo() {
|
||||
const systemVersion = process.getSystemVersion();
|
||||
const osName = process.platform;
|
||||
const osRelease = os.release();
|
||||
ElectronLog.info({ osName, osRelease, systemVersion });
|
||||
const appVersion = app.getVersion();
|
||||
ElectronLog.info({ appVersion });
|
||||
}
|
||||
|
||||
export function handleExternalLinks(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (!url.startsWith(PROD_HOST_URL)) {
|
||||
require('electron').shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
} else {
|
||||
return { action: 'allow' };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkIfInstalledViaBrew() {
|
||||
if (!isPlatform('mac')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await execAsync('brew list --cask ente');
|
||||
ElectronLog.info('ente installed via brew');
|
||||
return true;
|
||||
} catch (e) {
|
||||
ElectronLog.info('ente not installed via brew');
|
||||
return false;
|
||||
}
|
||||
}
|
218
desktop/src/utils/menu.ts
Normal file
|
@ -0,0 +1,218 @@
|
|||
import {
|
||||
Menu,
|
||||
app,
|
||||
shell,
|
||||
BrowserWindow,
|
||||
MenuItemConstructorOptions,
|
||||
} from 'electron';
|
||||
import {
|
||||
getHideDockIconPreference,
|
||||
setHideDockIconPreference,
|
||||
} from '../services/userPreference';
|
||||
import { setIsAppQuitting } from '../main';
|
||||
import autoLauncher from '../services/autoLauncher';
|
||||
import { isPlatform } from './common/platform';
|
||||
import ElectronLog from 'electron-log';
|
||||
import { forceCheckForUpdateAndNotify } from '../services/appUpdater';
|
||||
|
||||
export function buildContextMenu(mainWindow: BrowserWindow): Menu {
|
||||
// eslint-disable-next-line camelcase
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open ente',
|
||||
click: function () {
|
||||
mainWindow.maximize();
|
||||
mainWindow.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Quit ente',
|
||||
click: function () {
|
||||
ElectronLog.log('user quit the app');
|
||||
setIsAppQuitting(true);
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
return contextMenu;
|
||||
}
|
||||
|
||||
export async function buildMenuBar(mainWindow: BrowserWindow): Promise<Menu> {
|
||||
let isAutoLaunchEnabled = await autoLauncher.isEnabled();
|
||||
const isMac = isPlatform('mac');
|
||||
let shouldHideDockIcon = getHideDockIconPreference();
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: 'ente',
|
||||
submenu: [
|
||||
...((isMac
|
||||
? [
|
||||
{
|
||||
label: 'About ente',
|
||||
role: 'about',
|
||||
},
|
||||
]
|
||||
: []) as MenuItemConstructorOptions[]),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Check for updates...',
|
||||
click: () => {
|
||||
forceCheckForUpdateAndNotify(mainWindow);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'View Changelog',
|
||||
click: () => {
|
||||
shell.openExternal(
|
||||
'https://github.com/ente-io/photos-desktop/blob/main/CHANGELOG.md'
|
||||
);
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
|
||||
{
|
||||
label: 'Preferences',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open ente on startup',
|
||||
type: 'checkbox',
|
||||
checked: isAutoLaunchEnabled,
|
||||
click: () => {
|
||||
autoLauncher.toggleAutoLaunch();
|
||||
isAutoLaunchEnabled = !isAutoLaunchEnabled;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hide dock icon',
|
||||
type: 'checkbox',
|
||||
checked: shouldHideDockIcon,
|
||||
click: () => {
|
||||
setHideDockIconPreference(!shouldHideDockIcon);
|
||||
shouldHideDockIcon = !shouldHideDockIcon;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{ type: 'separator' },
|
||||
...((isMac
|
||||
? [
|
||||
{
|
||||
label: 'Hide ente',
|
||||
role: 'hide',
|
||||
},
|
||||
{
|
||||
label: 'Hide others',
|
||||
role: 'hideOthers',
|
||||
},
|
||||
]
|
||||
: []) as MenuItemConstructorOptions[]),
|
||||
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit ente',
|
||||
role: 'quit',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo', label: 'Undo' },
|
||||
{ role: 'redo', label: 'Redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut', label: 'Cut' },
|
||||
{ role: 'copy', label: 'Copy' },
|
||||
{ role: 'paste', label: 'Paste' },
|
||||
...((isMac
|
||||
? [
|
||||
{
|
||||
role: 'pasteAndMatchStyle',
|
||||
label: 'Paste and match style',
|
||||
},
|
||||
{ role: 'delete', label: 'Delete' },
|
||||
{ role: 'selectAll', label: 'Select all' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [
|
||||
{
|
||||
role: 'startSpeaking',
|
||||
label: 'start speaking',
|
||||
},
|
||||
{
|
||||
role: 'stopSpeaking',
|
||||
label: 'stop speaking',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ type: 'separator' },
|
||||
{ role: 'selectAll', label: 'Select all' },
|
||||
]) as MenuItemConstructorOptions[]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload', label: 'Reload' },
|
||||
{ role: 'forceReload', label: 'Force reload' },
|
||||
{ role: 'toggleDevTools', label: 'Toggle dev tools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom', label: 'Reset zoom' },
|
||||
{ role: 'zoomIn', label: 'Zoom in' },
|
||||
{ role: 'zoomOut', label: 'Zoom out' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen', label: 'Toggle fullscreen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'close', label: 'Close' },
|
||||
{ role: 'minimize', label: 'Minimize' },
|
||||
...((isMac
|
||||
? [
|
||||
{ type: 'separator' },
|
||||
{ role: 'front', label: 'Bring to front' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'window', label: 'ente' },
|
||||
]
|
||||
: []) as MenuItemConstructorOptions[]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'FAQ',
|
||||
click: () => shell.openExternal('https://ente.io/faq/'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Support',
|
||||
click: () => shell.openExternal('mailto:support@ente.io'),
|
||||
},
|
||||
{
|
||||
label: 'Product updates',
|
||||
click: () => shell.openExternal('https://ente.io/blog/'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'View crash reports',
|
||||
click: () => {
|
||||
shell.openPath(app.getPath('crashDumps'));
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'View logs',
|
||||
click: () => {
|
||||
shell.openPath(app.getPath('logs'));
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return Menu.buildFromTemplate(template);
|
||||
}
|
16
desktop/src/utils/preload.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { webFrame } from 'electron';
|
||||
|
||||
export const fixHotReloadNext12 = () => {
|
||||
webFrame.executeJavaScript(`Object.defineProperty(globalThis, 'WebSocket', {
|
||||
value: new Proxy(WebSocket, {
|
||||
construct: (Target, [url, protocols]) => {
|
||||
if (url.endsWith('/_next/webpack-hmr')) {
|
||||
// Fix the Next.js hmr client url
|
||||
return new Target("ws://localhost:3000/_next/webpack-hmr", protocols)
|
||||
} else {
|
||||
return new Target(url, protocols)
|
||||
}
|
||||
}
|
||||
})
|
||||
});`);
|
||||
};
|
295
desktop/src/utils/processStats.ts
Normal file
|
@ -0,0 +1,295 @@
|
|||
import ElectronLog from 'electron-log';
|
||||
import { webFrame } from 'electron/renderer';
|
||||
import { convertBytesToHumanReadable } from './logging';
|
||||
|
||||
const LOGGING_INTERVAL_IN_MICROSECONDS = 30 * 1000; // 30 seconds
|
||||
|
||||
const SPIKE_DETECTION_INTERVAL_IN_MICROSECONDS = 1 * 1000; // 1 seconds
|
||||
|
||||
const MAIN_MEMORY_USAGE_DIFF_IN_KILOBYTES_CONSIDERED_AS_SPIKE = 50 * 1024; // 50 MB
|
||||
|
||||
const HIGH_MAIN_MEMORY_USAGE_THRESHOLD_IN_KILOBYTES = 200 * 1024; // 200 MB
|
||||
|
||||
const RENDERER_MEMORY_USAGE_DIFF_IN_KILOBYTES_CONSIDERED_AS_SPIKE = 200 * 1024; // 200 MB
|
||||
|
||||
const HIGH_RENDERER_MEMORY_USAGE_THRESHOLD_IN_KILOBYTES = 1024 * 1024; // 1 GB
|
||||
|
||||
async function logMainProcessStats() {
|
||||
const processMemoryInfo = await getNormalizedProcessMemoryInfo(
|
||||
await process.getProcessMemoryInfo()
|
||||
);
|
||||
const cpuUsage = process.getCPUUsage();
|
||||
const heapStatistics = getNormalizedHeapStatistics(
|
||||
process.getHeapStatistics()
|
||||
);
|
||||
|
||||
ElectronLog.log('main process stats', {
|
||||
processMemoryInfo,
|
||||
heapStatistics,
|
||||
cpuUsage,
|
||||
});
|
||||
}
|
||||
|
||||
let previousMainProcessMemoryInfo: Electron.ProcessMemoryInfo = {
|
||||
private: 0,
|
||||
shared: 0,
|
||||
residentSet: 0,
|
||||
};
|
||||
|
||||
let mainProcessUsingHighMemory = false;
|
||||
|
||||
async function logSpikeMainMemoryUsage() {
|
||||
const processMemoryInfo = await process.getProcessMemoryInfo();
|
||||
const currentMemoryUsage = Math.max(
|
||||
processMemoryInfo.residentSet ?? 0,
|
||||
processMemoryInfo.private
|
||||
);
|
||||
const previousMemoryUsage = Math.max(
|
||||
previousMainProcessMemoryInfo.residentSet ?? 0,
|
||||
previousMainProcessMemoryInfo.private
|
||||
);
|
||||
const isSpiking =
|
||||
currentMemoryUsage - previousMemoryUsage >=
|
||||
MAIN_MEMORY_USAGE_DIFF_IN_KILOBYTES_CONSIDERED_AS_SPIKE;
|
||||
|
||||
const isHighMemoryUsage =
|
||||
currentMemoryUsage >= HIGH_MAIN_MEMORY_USAGE_THRESHOLD_IN_KILOBYTES;
|
||||
|
||||
const shouldReport =
|
||||
(isHighMemoryUsage && !mainProcessUsingHighMemory) ||
|
||||
(!isHighMemoryUsage && mainProcessUsingHighMemory);
|
||||
|
||||
if (isSpiking || shouldReport) {
|
||||
const normalizedCurrentProcessMemoryInfo =
|
||||
await getNormalizedProcessMemoryInfo(processMemoryInfo);
|
||||
const normalizedPreviousProcessMemoryInfo =
|
||||
await getNormalizedProcessMemoryInfo(previousMainProcessMemoryInfo);
|
||||
const cpuUsage = process.getCPUUsage();
|
||||
const heapStatistics = getNormalizedHeapStatistics(
|
||||
process.getHeapStatistics()
|
||||
);
|
||||
|
||||
ElectronLog.log('reporting main memory usage spike', {
|
||||
currentProcessMemoryInfo: normalizedCurrentProcessMemoryInfo,
|
||||
previousProcessMemoryInfo: normalizedPreviousProcessMemoryInfo,
|
||||
heapStatistics,
|
||||
cpuUsage,
|
||||
});
|
||||
}
|
||||
previousMainProcessMemoryInfo = processMemoryInfo;
|
||||
if (shouldReport) {
|
||||
mainProcessUsingHighMemory = !mainProcessUsingHighMemory;
|
||||
}
|
||||
}
|
||||
|
||||
let previousRendererProcessMemoryInfo: Electron.ProcessMemoryInfo = {
|
||||
private: 0,
|
||||
shared: 0,
|
||||
residentSet: 0,
|
||||
};
|
||||
|
||||
let rendererUsingHighMemory = false;
|
||||
|
||||
async function logSpikeRendererMemoryUsage() {
|
||||
const processMemoryInfo = await process.getProcessMemoryInfo();
|
||||
const currentMemoryUsage = Math.max(
|
||||
processMemoryInfo.residentSet ?? 0,
|
||||
processMemoryInfo.private
|
||||
);
|
||||
|
||||
const previousMemoryUsage = Math.max(
|
||||
previousRendererProcessMemoryInfo.private,
|
||||
previousRendererProcessMemoryInfo.residentSet ?? 0
|
||||
);
|
||||
const isSpiking =
|
||||
currentMemoryUsage - previousMemoryUsage >=
|
||||
RENDERER_MEMORY_USAGE_DIFF_IN_KILOBYTES_CONSIDERED_AS_SPIKE;
|
||||
|
||||
const isHighMemoryUsage =
|
||||
currentMemoryUsage >= HIGH_RENDERER_MEMORY_USAGE_THRESHOLD_IN_KILOBYTES;
|
||||
|
||||
const shouldReport =
|
||||
(isHighMemoryUsage && !rendererUsingHighMemory) ||
|
||||
(!isHighMemoryUsage && rendererUsingHighMemory);
|
||||
|
||||
if (isSpiking || shouldReport) {
|
||||
const normalizedCurrentProcessMemoryInfo =
|
||||
await getNormalizedProcessMemoryInfo(processMemoryInfo);
|
||||
const normalizedPreviousProcessMemoryInfo =
|
||||
await getNormalizedProcessMemoryInfo(
|
||||
previousRendererProcessMemoryInfo
|
||||
);
|
||||
const cpuUsage = process.getCPUUsage();
|
||||
const heapStatistics = getNormalizedHeapStatistics(
|
||||
process.getHeapStatistics()
|
||||
);
|
||||
|
||||
ElectronLog.log('reporting renderer memory usage spike', {
|
||||
currentProcessMemoryInfo: normalizedCurrentProcessMemoryInfo,
|
||||
previousProcessMemoryInfo: normalizedPreviousProcessMemoryInfo,
|
||||
heapStatistics,
|
||||
cpuUsage,
|
||||
});
|
||||
}
|
||||
previousRendererProcessMemoryInfo = processMemoryInfo;
|
||||
if (shouldReport) {
|
||||
rendererUsingHighMemory = !rendererUsingHighMemory;
|
||||
}
|
||||
}
|
||||
|
||||
async function logRendererProcessStats() {
|
||||
const blinkMemoryInfo = getNormalizedBlinkMemoryInfo();
|
||||
const heapStatistics = getNormalizedHeapStatistics(
|
||||
process.getHeapStatistics()
|
||||
);
|
||||
const webFrameResourceUsage = getNormalizedWebFrameResourceUsage();
|
||||
const processMemoryInfo = await getNormalizedProcessMemoryInfo(
|
||||
await process.getProcessMemoryInfo()
|
||||
);
|
||||
ElectronLog.log('renderer process stats', {
|
||||
blinkMemoryInfo,
|
||||
heapStatistics,
|
||||
processMemoryInfo,
|
||||
webFrameResourceUsage,
|
||||
});
|
||||
}
|
||||
|
||||
export function setupMainProcessStatsLogger() {
|
||||
setInterval(
|
||||
logSpikeMainMemoryUsage,
|
||||
SPIKE_DETECTION_INTERVAL_IN_MICROSECONDS
|
||||
);
|
||||
setInterval(logMainProcessStats, LOGGING_INTERVAL_IN_MICROSECONDS);
|
||||
}
|
||||
|
||||
export function setupRendererProcessStatsLogger() {
|
||||
setInterval(
|
||||
logSpikeRendererMemoryUsage,
|
||||
SPIKE_DETECTION_INTERVAL_IN_MICROSECONDS
|
||||
);
|
||||
setInterval(logRendererProcessStats, LOGGING_INTERVAL_IN_MICROSECONDS);
|
||||
}
|
||||
|
||||
export async function logRendererProcessMemoryUsage(message: string) {
|
||||
const processMemoryInfo = await process.getProcessMemoryInfo();
|
||||
const processMemory = Math.max(
|
||||
processMemoryInfo.private,
|
||||
processMemoryInfo.residentSet ?? 0
|
||||
);
|
||||
ElectronLog.log(
|
||||
'renderer ProcessMemory',
|
||||
message,
|
||||
convertBytesToHumanReadable(processMemory * 1024)
|
||||
);
|
||||
}
|
||||
|
||||
const getNormalizedProcessMemoryInfo = async (
|
||||
processMemoryInfo: Electron.ProcessMemoryInfo
|
||||
) => {
|
||||
return {
|
||||
residentSet: convertBytesToHumanReadable(
|
||||
processMemoryInfo.residentSet * 1024
|
||||
),
|
||||
private: convertBytesToHumanReadable(processMemoryInfo.private * 1024),
|
||||
shared: convertBytesToHumanReadable(processMemoryInfo.shared * 1024),
|
||||
};
|
||||
};
|
||||
|
||||
const getNormalizedBlinkMemoryInfo = () => {
|
||||
const blinkMemoryInfo = process.getBlinkMemoryInfo();
|
||||
return {
|
||||
allocated: convertBytesToHumanReadable(
|
||||
blinkMemoryInfo.allocated * 1024
|
||||
),
|
||||
total: convertBytesToHumanReadable(blinkMemoryInfo.total * 1024),
|
||||
};
|
||||
};
|
||||
|
||||
const getNormalizedHeapStatistics = (
|
||||
heapStatistics: Electron.HeapStatistics
|
||||
) => {
|
||||
return {
|
||||
totalHeapSize: convertBytesToHumanReadable(
|
||||
heapStatistics.totalHeapSize * 1024
|
||||
),
|
||||
totalHeapSizeExecutable: convertBytesToHumanReadable(
|
||||
heapStatistics.totalHeapSizeExecutable * 1024
|
||||
),
|
||||
totalPhysicalSize: convertBytesToHumanReadable(
|
||||
heapStatistics.totalPhysicalSize * 1024
|
||||
),
|
||||
totalAvailableSize: convertBytesToHumanReadable(
|
||||
heapStatistics.totalAvailableSize * 1024
|
||||
),
|
||||
usedHeapSize: convertBytesToHumanReadable(
|
||||
heapStatistics.usedHeapSize * 1024
|
||||
),
|
||||
|
||||
heapSizeLimit: convertBytesToHumanReadable(
|
||||
heapStatistics.heapSizeLimit * 1024
|
||||
),
|
||||
mallocedMemory: convertBytesToHumanReadable(
|
||||
heapStatistics.mallocedMemory * 1024
|
||||
),
|
||||
peakMallocedMemory: convertBytesToHumanReadable(
|
||||
heapStatistics.peakMallocedMemory * 1024
|
||||
),
|
||||
doesZapGarbage: heapStatistics.doesZapGarbage,
|
||||
};
|
||||
};
|
||||
|
||||
const getNormalizedWebFrameResourceUsage = () => {
|
||||
const webFrameResourceUsage = webFrame.getResourceUsage();
|
||||
return {
|
||||
images: {
|
||||
count: webFrameResourceUsage.images.count,
|
||||
size: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.images.size
|
||||
),
|
||||
liveSize: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.images.liveSize
|
||||
),
|
||||
},
|
||||
scripts: {
|
||||
count: webFrameResourceUsage.scripts.count,
|
||||
size: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.scripts.size
|
||||
),
|
||||
liveSize: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.scripts.liveSize
|
||||
),
|
||||
},
|
||||
cssStyleSheets: {
|
||||
count: webFrameResourceUsage.cssStyleSheets.count,
|
||||
size: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.cssStyleSheets.size
|
||||
),
|
||||
liveSize: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.cssStyleSheets.liveSize
|
||||
),
|
||||
},
|
||||
xslStyleSheets: {
|
||||
count: webFrameResourceUsage.xslStyleSheets.count,
|
||||
size: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.xslStyleSheets.size
|
||||
),
|
||||
liveSize: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.xslStyleSheets.liveSize
|
||||
),
|
||||
},
|
||||
fonts: {
|
||||
count: webFrameResourceUsage.fonts.count,
|
||||
size: convertBytesToHumanReadable(webFrameResourceUsage.fonts.size),
|
||||
liveSize: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.fonts.liveSize
|
||||
),
|
||||
},
|
||||
other: {
|
||||
count: webFrameResourceUsage.other.count,
|
||||
size: convertBytesToHumanReadable(webFrameResourceUsage.other.size),
|
||||
liveSize: convertBytesToHumanReadable(
|
||||
webFrameResourceUsage.other.liveSize
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
38
desktop/src/utils/temp.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync, mkdir } from 'promise-fs';
|
||||
|
||||
const ENTE_TEMP_DIRECTORY = 'ente';
|
||||
|
||||
const CHARACTERS =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
export async function getTempDirPath() {
|
||||
const tempDirPath = path.join(app.getPath('temp'), ENTE_TEMP_DIRECTORY);
|
||||
if (!existsSync(tempDirPath)) {
|
||||
await mkdir(tempDirPath);
|
||||
}
|
||||
return tempDirPath;
|
||||
}
|
||||
|
||||
function generateTempName(length: number) {
|
||||
let result = '';
|
||||
|
||||
const charactersLength = CHARACTERS.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += CHARACTERS.charAt(
|
||||
Math.floor(Math.random() * charactersLength)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generateTempFilePath(formatSuffix: string) {
|
||||
let tempFilePath: string;
|
||||
do {
|
||||
const tempDirPath = await getTempDirPath();
|
||||
const namePrefix = generateTempName(10);
|
||||
tempFilePath = path.join(tempDirPath, namePrefix + '-' + formatSuffix);
|
||||
} while (existsSync(tempFilePath));
|
||||
return tempFilePath;
|
||||
}
|
11
desktop/src/utils/watch.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { WatchMapping } from '../types';
|
||||
|
||||
export function isMappingPresent(
|
||||
watchMappings: WatchMapping[],
|
||||
folderPath: string
|
||||
) {
|
||||
const watchMapping = watchMappings?.find(
|
||||
(mapping) => mapping.folderPath === folderPath
|
||||
);
|
||||
return !!watchMapping;
|
||||
}
|
1
desktop/thirdparty/next-electron-server
vendored
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit a88030295c89dd8f43d9e3a45025678d95c78a45
|
15
desktop/tsconfig.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "app",
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"*": ["node_modules/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
1
desktop/ui
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 855a58e4df492331f57672db1819e959886115dd
|