fix: logics and ui (button, error code)
This commit is contained in:
commit
d1a15b129a
200 changed files with 6006 additions and 5567 deletions
6
.github/workflows/auth-release.yml
vendored
6
.github/workflows/auth-release.yml
vendored
|
@ -17,8 +17,8 @@ name: "Release (auth)"
|
|||
# We use a suffix like `-test` to indicate that these are test tags, and that
|
||||
# they belong to a pre-release.
|
||||
#
|
||||
# If you need to do multiple tests, add a +x at the end of the tag. e.g.
|
||||
# `auth-v1.2.3-test+1`.
|
||||
# If you need to do multiple tests, add a .x at the end of the tag. e.g.
|
||||
# `auth-v1.2.3-test.1`.
|
||||
#
|
||||
# Once the testing is done, also delete the tag(s) please.
|
||||
|
||||
|
@ -85,7 +85,7 @@ jobs:
|
|||
- name: Install dependencies for desktop build
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y libsecret-1-dev libsodium-dev libwebkit2gtk-4.0-dev libfuse2 ninja-build libgtk-3-dev dpkg-dev pkg-config rpm libsqlite3-dev locate appindicator3-0.1 libappindicator3-dev libffi-dev libtiff5
|
||||
sudo apt-get install -y libsecret-1-dev libsodium-dev libwebkit2gtk-4.0-dev libfuse2 ninja-build libgtk-3-dev dpkg-dev pkg-config rpm patchelf libsqlite3-dev locate appindicator3-0.1 libappindicator3-dev libffi-dev libtiff5
|
||||
sudo updatedb --localpaths='/usr/lib/x86_64-linux-gnu'
|
||||
|
||||
- name: Install appimagetool
|
||||
|
|
30
.github/workflows/desktop-lint.yml
vendored
Normal file
30
.github/workflows/desktop-lint.yml
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
name: "Lint (desktop)"
|
||||
|
||||
on:
|
||||
# Run on every push to a branch other than main that changes desktop/
|
||||
push:
|
||||
branches-ignore: [main, "deploy/**"]
|
||||
paths:
|
||||
- "desktop/**"
|
||||
- ".github/workflows/desktop-lint.yml"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup node and enable yarn caching
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "yarn"
|
||||
cache-dependency-path: "desktop/yarn.lock"
|
||||
|
||||
- run: yarn install
|
||||
|
||||
- run: yarn lint
|
|
@ -54,3 +54,4 @@ jobs:
|
|||
packageName: io.ente.photos
|
||||
releaseFiles: mobile/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab
|
||||
track: internal
|
||||
changesNotSentForReview: true
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:ente_auth/models/code_display.dart';
|
||||
import 'package:ente_auth/utils/totp_util.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class Code {
|
||||
static const defaultDigits = 6;
|
||||
static const steamDigits = 5;
|
||||
static const defaultPeriod = 30;
|
||||
|
||||
int? generatedID;
|
||||
|
@ -70,39 +68,45 @@ class Code {
|
|||
updatedAlgo,
|
||||
updatedType,
|
||||
updatedCounter,
|
||||
"otpauth://${updatedType.name}/$updateIssuer:$updateAccount?algorithm=${updatedAlgo.name}&digits=$updatedDigits&issuer=$updateIssuer&period=$updatePeriod&secret=$updatedSecret${updatedType == Type.hotp ? "&counter=$updatedCounter" : ""}",
|
||||
"otpauth://${updatedType.name}/$updateIssuer:$updateAccount?algorithm=${updatedAlgo.name}"
|
||||
"&digits=$updatedDigits&issuer=$updateIssuer"
|
||||
"&period=$updatePeriod&secret=$updatedSecret${updatedType == Type.hotp ? "&counter=$updatedCounter" : ""}",
|
||||
generatedID: generatedID,
|
||||
display: updatedDisplay,
|
||||
);
|
||||
}
|
||||
|
||||
static Code fromAccountAndSecret(
|
||||
Type type,
|
||||
String account,
|
||||
String issuer,
|
||||
String secret,
|
||||
CodeDisplay? display,
|
||||
int digits,
|
||||
) {
|
||||
return Code(
|
||||
account,
|
||||
issuer,
|
||||
defaultDigits,
|
||||
digits,
|
||||
defaultPeriod,
|
||||
secret,
|
||||
Algorithm.sha1,
|
||||
Type.totp,
|
||||
type,
|
||||
0,
|
||||
"otpauth://totp/$issuer:$account?algorithm=SHA1&digits=6&issuer=$issuer&period=30&secret=$secret",
|
||||
"otpauth://${type.name}/$issuer:$account?algorithm=SHA1&digits=$digits&issuer=$issuer&period=30&secret=$secret",
|
||||
display: display ?? CodeDisplay(),
|
||||
);
|
||||
}
|
||||
|
||||
static Code fromOTPAuthUrl(String rawData, {CodeDisplay? display}) {
|
||||
Uri uri = Uri.parse(rawData);
|
||||
final issuer = _getIssuer(uri);
|
||||
|
||||
try {
|
||||
return Code(
|
||||
_getAccount(uri),
|
||||
_getIssuer(uri),
|
||||
_getDigits(uri),
|
||||
issuer,
|
||||
_getDigits(uri, issuer),
|
||||
_getPeriod(uri),
|
||||
getSanitizedSecret(uri.queryParameters['secret']!),
|
||||
_getAlgorithm(uri),
|
||||
|
@ -179,10 +183,13 @@ class Code {
|
|||
}
|
||||
}
|
||||
|
||||
static int _getDigits(Uri uri) {
|
||||
static int _getDigits(Uri uri, String issuer) {
|
||||
try {
|
||||
return int.parse(uri.queryParameters['digits']!);
|
||||
} catch (e) {
|
||||
if (issuer.toLowerCase() == "steam") {
|
||||
return steamDigits;
|
||||
}
|
||||
return defaultDigits;
|
||||
}
|
||||
}
|
||||
|
@ -225,6 +232,8 @@ class Code {
|
|||
static Type _getType(Uri uri) {
|
||||
if (uri.host == "totp") {
|
||||
return Type.totp;
|
||||
} else if (uri.host == "steam") {
|
||||
return Type.steam;
|
||||
} else if (uri.host == "hotp") {
|
||||
return Type.hotp;
|
||||
}
|
||||
|
@ -262,6 +271,9 @@ class Code {
|
|||
enum Type {
|
||||
totp,
|
||||
hotp,
|
||||
steam;
|
||||
|
||||
bool get isTOTPCompatible => this == totp || this == steam;
|
||||
}
|
||||
|
||||
enum Algorithm {
|
||||
|
|
|
@ -90,6 +90,8 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
|||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.codeIssuerHint,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelText: l10n.codeIssuerHint,
|
||||
),
|
||||
controller: _issuerController,
|
||||
autofocus: true,
|
||||
|
@ -107,6 +109,8 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
|||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.codeSecretKeyHint,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelText: l10n.codeSecretKeyHint,
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
|
@ -134,9 +138,12 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
|||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.codeAccountHint,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
labelText: l10n.codeAccountHint,
|
||||
),
|
||||
controller: _accountController,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
|
@ -218,6 +225,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
|||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Text(l10n.saveAction),
|
||||
),
|
||||
|
@ -236,6 +244,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
|||
final account = _accountController.text.trim();
|
||||
final issuer = _issuerController.text.trim();
|
||||
final secret = _secretController.text.trim().replaceAll(' ', '');
|
||||
final isStreamCode = issuer.toLowerCase() == "steam";
|
||||
if (widget.code != null && widget.code!.secret != secret) {
|
||||
ButtonResult? result = await showChoiceActionSheet(
|
||||
context,
|
||||
|
@ -253,9 +262,11 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
|
|||
final CodeDisplay display = widget.code!.display.copyWith(tags: tags);
|
||||
final Code newCode = widget.code == null
|
||||
? Code.fromAccountAndSecret(
|
||||
isStreamCode ? Type.steam : Type.totp,
|
||||
account,
|
||||
issuer,
|
||||
secret,
|
||||
isStreamCode ? Code.steamDigits : Code.defaultDigits,
|
||||
display,
|
||||
)
|
||||
: widget.code!.copyWith(
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:ente_auth/core/configuration.dart';
|
||||
|
@ -61,7 +60,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||
String newCode = _getCurrentOTP();
|
||||
if (newCode != _currentCode.value) {
|
||||
_currentCode.value = newCode;
|
||||
if (widget.code.type == Type.totp) {
|
||||
if (widget.code.type.isTOTPCompatible) {
|
||||
_nextCode.value = _getNextTotp();
|
||||
}
|
||||
}
|
||||
|
@ -86,7 +85,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||
_shouldShowLargeIcon = PreferenceService.instance.shouldShowLargeIcons();
|
||||
if (!_isInitialized) {
|
||||
_currentCode.value = _getCurrentOTP();
|
||||
if (widget.code.type == Type.totp) {
|
||||
if (widget.code.type.isTOTPCompatible) {
|
||||
_nextCode.value = _getNextTotp();
|
||||
}
|
||||
_isInitialized = true;
|
||||
|
@ -373,7 +372,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||
},
|
||||
),
|
||||
),
|
||||
widget.code.type == Type.totp
|
||||
widget.code.type.isTOTPCompatible
|
||||
? GestureDetector(
|
||||
onTap: () {
|
||||
_copyNextToClipboard();
|
||||
|
@ -610,7 +609,7 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||
|
||||
String _getNextTotp() {
|
||||
try {
|
||||
assert(widget.code.type == Type.totp);
|
||||
assert(widget.code.type.isTOTPCompatible);
|
||||
return getNextTotp(widget.code);
|
||||
} catch (e) {
|
||||
return context.l10n.error;
|
||||
|
|
|
@ -92,6 +92,7 @@ Future<int?> _processBitwardenExportFile(
|
|||
var account = item['login']['username'];
|
||||
|
||||
code = Code.fromAccountAndSecret(
|
||||
Type.totp,
|
||||
account,
|
||||
issuer,
|
||||
totp,
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:otp/otp.dart' as otp;
|
||||
|
||||
String getOTP(Code code) {
|
||||
if(code.type == Type.hotp) {
|
||||
if (code.type == Type.hotp) {
|
||||
return _getHOTPCode(code);
|
||||
}
|
||||
return otp.OTP.generateTOTPCodeString(
|
||||
|
@ -60,4 +60,4 @@ String safeDecode(String value) {
|
|||
debugPrint("Failed to decode $e");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ display_name: Auth
|
|||
|
||||
requires:
|
||||
- libsqlite3x
|
||||
- webkit2gtk-4.0
|
||||
- webkit2gtk4.0
|
||||
- libsodium
|
||||
- libsecret
|
||||
- libappindicator
|
||||
|
|
|
@ -20,7 +20,8 @@ dependencies:
|
|||
convert: ^3.1.1
|
||||
desktop_webview_window:
|
||||
git:
|
||||
url: https://github.com/MixinNetwork/flutter-plugins
|
||||
url: https://github.com/ente-io/flutter-desktopwebview-fork
|
||||
ref: fix-webkit-version
|
||||
path: packages/desktop_webview_window
|
||||
device_info_plus: ^9.1.1
|
||||
dio: ^5.4.0
|
||||
|
|
|
@ -36,7 +36,8 @@ ente --help
|
|||
|
||||
### Accounts
|
||||
|
||||
If you wish, you can add multiple accounts (your own and that of your family members) and export all data using this tool.
|
||||
If you wish, you can add multiple accounts (your own and that of your family
|
||||
members) and export all data using this tool.
|
||||
|
||||
#### Add an account
|
||||
|
||||
|
@ -44,6 +45,12 @@ If you wish, you can add multiple accounts (your own and that of your family mem
|
|||
ente account add
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> `ente account add` does not create new accounts, it just adds pre-existing
|
||||
> accounts to the list of accounts that the CLI knows about so that you can use
|
||||
> them for other actions.
|
||||
|
||||
#### List accounts
|
||||
|
||||
```shell
|
||||
|
|
|
@ -1,26 +1,36 @@
|
|||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
/* What we really want eventually */
|
||||
// "plugin:@typescript-eslint/strict-type-checked",
|
||||
// "plugin:@typescript-eslint/stylistic-type-checked",
|
||||
"plugin:@typescript-eslint/strict-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
||||
],
|
||||
/* Temporarily add a global
|
||||
Enhancement: Remove me */
|
||||
globals: {
|
||||
NodeJS: "readonly",
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
root: true,
|
||||
ignorePatterns: [".eslintrc.js", "app", "out", "dist"],
|
||||
env: {
|
||||
es2022: true,
|
||||
node: true,
|
||||
},
|
||||
rules: {
|
||||
/* Allow numbers to be used in template literals */
|
||||
"@typescript-eslint/restrict-template-expressions": [
|
||||
"error",
|
||||
{
|
||||
allowNumber: true,
|
||||
},
|
||||
],
|
||||
/* Allow void expressions as the entire body of an arrow function */
|
||||
"@typescript-eslint/no-confusing-void-expression": [
|
||||
"error",
|
||||
{
|
||||
ignoreArrowShorthand: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
55
desktop/.github/workflows/build.yml
vendored
55
desktop/.github/workflows/build.yml
vendored
|
@ -1,55 +0,0 @@
|
|||
name: Build/release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Node.js, NPM and Yarn
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Prepare for app notarization
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
# Import Apple API key for app notarization on macOS
|
||||
run: |
|
||||
mkdir -p ~/private_keys/
|
||||
echo '${{ secrets.api_key }}' > ~/private_keys/AuthKey_${{ secrets.api_key_id }}.p8
|
||||
|
||||
- name: Install libarchive-tools for pacman build # Related https://github.com/electron-userland/electron-builder/issues/4181
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: sudo apt-get install libarchive-tools
|
||||
|
||||
- name: Ente Electron Builder Action
|
||||
uses: ente-io/action-electron-builder@v1.0.0
|
||||
with:
|
||||
# GitHub token, automatically provided to the action
|
||||
# (No need to define this secret in the repo settings)
|
||||
github_token: ${{ secrets.github_token }}
|
||||
|
||||
# If the commit is tagged with a version (e.g. "v1.0.0"),
|
||||
# release the app after building
|
||||
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
mac_certs: ${{ secrets.mac_certs }}
|
||||
mac_certs_password: ${{ secrets.mac_certs_password }}
|
||||
env:
|
||||
# macOS notarization API key
|
||||
API_KEY_ID: ${{ secrets.api_key_id }}
|
||||
API_KEY_ISSUER_ID: ${{ secrets.api_key_issuer_id}}
|
||||
USE_HARD_LINKS: false
|
83
desktop/.github/workflows/desktop-release.yml
vendored
Normal file
83
desktop/.github/workflows/desktop-release.yml
vendored
Normal file
|
@ -0,0 +1,83 @@
|
|||
name: "Release"
|
||||
|
||||
# This will create a new draft release with public artifacts.
|
||||
#
|
||||
# Note that a release will only get created if there is an associated tag
|
||||
# (GitHub releases need a corresponding tag).
|
||||
#
|
||||
# The canonical source for this action is in the repository where we keep the
|
||||
# source code for the Ente Photos desktop app: https://github.com/ente-io/ente
|
||||
#
|
||||
# However, it actually lives and runs in the repository that we use for making
|
||||
# releases: https://github.com/ente-io/photos-desktop
|
||||
#
|
||||
# We need two repositories because Electron updater currently doesn't work well
|
||||
# with monorepos. For more details, see `docs/release.md`.
|
||||
|
||||
on:
|
||||
push:
|
||||
# Run when a tag matching the pattern "v*"" is pushed.
|
||||
#
|
||||
# See: [Note: Testing release workflows that are triggered by tags].
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Checkout the tag photosd-v1.x.x from the source code
|
||||
# repository when we're invoked for tag v1.x.x on the releases
|
||||
# repository.
|
||||
repository: ente-io/ente
|
||||
ref: photosd-${{ github.ref_name }}
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Install libarchive-tools for pacman build
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
# See:
|
||||
# https://github.com/electron-userland/electron-builder/issues/4181
|
||||
run: sudo apt-get install libarchive-tools
|
||||
|
||||
- name: Build
|
||||
uses: ente-io/action-electron-builder@v1.0.0
|
||||
with:
|
||||
package_root: desktop
|
||||
|
||||
# GitHub token, automatically provided to the action
|
||||
# (No need to define this secret in the repo settings)
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# If the commit is tagged with a version (e.g. "v1.0.0"),
|
||||
# release the app after building.
|
||||
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
mac_certs: ${{ secrets.MAC_CERTS }}
|
||||
mac_certs_password: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
env:
|
||||
# macOS notarization credentials key details
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD:
|
||||
${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
USE_HARD_LINKS: false
|
|
@ -1,5 +1,13 @@
|
|||
# CHANGELOG
|
||||
|
||||
## v1.7.0 (Unreleased)
|
||||
|
||||
v1.7 is a major rewrite to improve the security of our app. We have enabled
|
||||
sandboxing and disabled node integration for the renderer process. All this
|
||||
required restructuring our IPC mechanisms, which resulted in a lot of under the
|
||||
hood changes. The outcome is a more secure app that also uses the latest and
|
||||
greatest Electron recommendations.
|
||||
|
||||
## v1.6.63
|
||||
|
||||
### New
|
||||
|
|
|
@ -10,12 +10,6 @@ To know more about Ente, see [our main README](../README.md) or visit
|
|||
|
||||
## Building from source
|
||||
|
||||
> [!CAUTION]
|
||||
>
|
||||
> We're improving the security of the desktop app further by migrating to
|
||||
> Electron's sandboxing and contextIsolation. These updates are still WIP and
|
||||
> meanwhile the instructions below might not fully work on the main branch.
|
||||
|
||||
Fetch submodules
|
||||
|
||||
```sh
|
||||
|
|
|
@ -13,7 +13,7 @@ Electron embeds Chromium and Node.js in the generated app's binary. The
|
|||
generated app thus consists of two separate processes - the _main_ process, and
|
||||
a _renderer_ process.
|
||||
|
||||
- The _main_ process is runs the embedded node. This process can deal with the
|
||||
- The _main_ process runs the embedded node. This process can deal with the
|
||||
host OS - it is conceptually like a `node` repl running on your machine. In
|
||||
our case, the TypeScript code (in the `src/` directory) gets transpiled by
|
||||
`tsc` into JavaScript in the `build/app/` directory, which gets bundled in
|
||||
|
@ -90,6 +90,9 @@ Some extra ones specific to the code here are:
|
|||
Unix commands in our `package.json` scripts. This allows us to use the same
|
||||
commands (like `ln`) across different platforms like Linux and Windows.
|
||||
|
||||
- [@tsconfig/recommended](https://github.com/tsconfig/bases) gives us a base
|
||||
tsconfig for the Node.js version that our current Electron version uses.
|
||||
|
||||
## Functionality
|
||||
|
||||
### Format conversion
|
||||
|
|
|
@ -1,43 +1,47 @@
|
|||
## Releases
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> TODO(MR): This document needs to be audited and changed as we do the first
|
||||
> release from this new monorepo.
|
||||
Conceptually, the release is straightforward: We push a tag, a GitHub workflow
|
||||
gets triggered that creates a draft release with artifacts built from that tag.
|
||||
We then publish that release. The download links on our website, and existing
|
||||
apps already know how to check for the latest GitHub release and update
|
||||
accordingly.
|
||||
|
||||
The Github Action that builds the desktop binaries is triggered by pushing a tag
|
||||
matching the pattern `photos-desktop-v1.2.3`. This value should match the
|
||||
version in `package.json`.
|
||||
The complication comes by the fact that Electron Updater (the mechanism that we
|
||||
use for auto updates) doesn't work well with monorepos. So we need to keep a
|
||||
separate (non-mono) repository just for doing releases.
|
||||
|
||||
So the process for doing a release would be.
|
||||
- Source code lives here, in [ente-io/ente](https://github.com/ente-io/ente).
|
||||
|
||||
1. Create a new branch (can be named anything). On this branch, include your
|
||||
changes.
|
||||
- Releases are done from
|
||||
[ente-io/photos-desktop](https://github.com/ente-io/photos-desktop).
|
||||
|
||||
2. Mention the changes in `CHANGELOG.md`.
|
||||
## Workflow
|
||||
|
||||
3. Changing the `version` in `package.json` to `1.x.x`.
|
||||
The workflow is:
|
||||
|
||||
4. Commit and push to remote
|
||||
1. Finalize the changes in the source repo.
|
||||
|
||||
- Update the CHANGELOG.
|
||||
- Update the version in `package.json`
|
||||
- `git commit -m "[photosd] Release v1.2.3"`
|
||||
- Open PR, merge into main.
|
||||
|
||||
2. Tag the merge commit with a tag matching the pattern `photosd-v1.2.3`, where
|
||||
`1.2.3` is the version in `package.json`
|
||||
|
||||
```sh
|
||||
git add package.json && git commit -m 'Release v1.x.x'
|
||||
git tag v1.x.x
|
||||
git push && git push --tags
|
||||
git tag photosd-v1.x.x
|
||||
git push origin photosd-v1.x.x
|
||||
```
|
||||
|
||||
This by itself will already trigger a new release. The GitHub action will create
|
||||
a new draft release that can then be used as descibed below.
|
||||
3. Head over to the releases repository and run the trigger script, passing it
|
||||
the tag _without_ the `photosd-` prefix.
|
||||
|
||||
To wrap up, we also need to merge back these changes into main. So for that,
|
||||
```sh
|
||||
./.github/trigger-release.sh v1.x.x
|
||||
```
|
||||
|
||||
5. Open a PR for the branch that we're working on (where the above tag was
|
||||
pushed from) to get it merged into main.
|
||||
|
||||
6. In this PR, also increase the version number for the next release train. That
|
||||
is, supposed we just released `v4.0.1`. Then we'll change the version number
|
||||
in main to `v4.0.2-next.0`. Each pre-release will modify the `next.0` part.
|
||||
Finally, at the time of the next release, this'll become `v4.0.2`.
|
||||
## Post build
|
||||
|
||||
The GitHub Action runs on Windows, Linux and macOS. It produces the artifacts
|
||||
defined in the `build` value in `package.json`.
|
||||
|
@ -46,29 +50,11 @@ defined in the `build` value in `package.json`.
|
|||
- Linux - An AppImage, and 3 other packages (`.rpm`, `.deb`, `.pacman`)
|
||||
- macOS - A universal DMG
|
||||
|
||||
Additionally, the GitHub action notarizes the macOS DMG. For this it needs
|
||||
credentials provided via GitHub secrets.
|
||||
Additionally, the GitHub action notarizes and signs the macOS DMG (For this it
|
||||
uses credentials provided via GitHub secrets).
|
||||
|
||||
During the build the Sentry webpack plugin checks to see if SENTRY_AUTH_TOKEN is
|
||||
defined. If so, it uploads the sourcemaps for the renderer process to Sentry
|
||||
(For our GitHub action, the SENTRY_AUTH_TOKEN is defined as a GitHub secret).
|
||||
|
||||
The sourcemaps for the main (node) process are currently not sent to Sentry
|
||||
(this works fine in practice since the node process files are not minified, we
|
||||
only run `tsc`).
|
||||
|
||||
Once the build is done, a draft release with all these artifacts attached is
|
||||
created. The build is idempotent, so if something goes wrong and we need to
|
||||
re-run the GitHub action, just delete the draft release (if it got created) and
|
||||
start a new run by pushing a new tag (if some code changes are required).
|
||||
|
||||
If no code changes are required, say the build failed for some transient network
|
||||
or sentry issue, we can even be re-run by the build by going to Github Action
|
||||
age and rerun from there. This will re-trigger for the same tag.
|
||||
|
||||
If everything goes well, we'll have a release on GitHub, and the corresponding
|
||||
source maps for the renderer process uploaded to Sentry. There isn't anything
|
||||
else to do:
|
||||
To rollout the build, we need to publish the draft release. Thereafter,
|
||||
everything is automated:
|
||||
|
||||
- The website automatically redirects to the latest release on GitHub when
|
||||
people try to download.
|
||||
|
@ -76,7 +62,7 @@ else to do:
|
|||
- The file formats with support auto update (Windows `exe`, the Linux AppImage
|
||||
and the macOS DMG) also check the latest GitHub release automatically to
|
||||
download and apply the update (the rest of the formats don't support auto
|
||||
updates).
|
||||
updates yet).
|
||||
|
||||
- We're not putting the desktop app in other stores currently. It is available
|
||||
as a `brew cask`, but we only had to open a PR to add the initial formula,
|
||||
|
@ -87,6 +73,4 @@ else to do:
|
|||
We can also publish the draft releases by checking the "pre-release" option.
|
||||
Such releases don't cause any of the channels (our website, or the desktop app
|
||||
auto updater, or brew) to be notified, instead these are useful for giving links
|
||||
to pre-release builds to customers. Generally, in the version number for these
|
||||
we'll add a label to the version, e.g. the "beta.x" in `1.x.x-beta.x`. This
|
||||
should be done both in `package.json`, and what we tag the commit with.
|
||||
to pre-release builds to customers.
|
||||
|
|
|
@ -29,4 +29,5 @@ mac:
|
|||
arch: [universal]
|
||||
category: public.app-category.photography
|
||||
hardenedRuntime: true
|
||||
notarize: true
|
||||
afterSign: electron-builder-notarize
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
{
|
||||
"name": "ente",
|
||||
"version": "1.6.63",
|
||||
"version": "1.7.0-beta.0",
|
||||
"private": true,
|
||||
"description": "Desktop client for Ente Photos",
|
||||
"repository": "github:ente-io/photos-desktop",
|
||||
"author": "Ente <code@ente.io>",
|
||||
"main": "app/main.js",
|
||||
"scripts": {
|
||||
|
@ -15,8 +16,11 @@
|
|||
"dev-main": "tsc && electron app/main.js",
|
||||
"dev-renderer": "cd ../web && yarn install && yarn dev:photos",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"lint": "yarn prettier --check . && eslint --ext .ts src",
|
||||
"lint-fix": "yarn prettier --write . && eslint --fix --ext .ts src"
|
||||
"lint": "yarn prettier --check --log-level warn . && eslint --ext .ts src && yarn tsc",
|
||||
"lint-fix": "yarn prettier --write --log-level warn . && eslint --fix --ext .ts src && yarn tsc"
|
||||
},
|
||||
"resolutions": {
|
||||
"jackspeak": "2.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"any-shell-escape": "^0.1",
|
||||
|
@ -34,13 +38,14 @@
|
|||
"onnxruntime-node": "^1.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/auto-launch": "^5.0",
|
||||
"@types/ffmpeg-static": "^3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7",
|
||||
"@typescript-eslint/parser": "^7",
|
||||
"concurrently": "^8",
|
||||
"electron": "^29",
|
||||
"electron-builder": "^24",
|
||||
"electron": "^30",
|
||||
"electron-builder": "25.0.0-alpha.6",
|
||||
"electron-builder-notarize": "^1.5",
|
||||
"eslint": "^8",
|
||||
"prettier": "^3",
|
||||
|
|
|
@ -8,18 +8,15 @@
|
|||
*
|
||||
* https://www.electronjs.org/docs/latest/tutorial/process-model#the-main-process
|
||||
*/
|
||||
import { nativeImage } from "electron";
|
||||
import { app, BrowserWindow, Menu, protocol, Tray } from "electron/main";
|
||||
|
||||
import { nativeImage, shell } from "electron/common";
|
||||
import type { WebContents } from "electron/main";
|
||||
import { BrowserWindow, Menu, Tray, app, protocol } from "electron/main";
|
||||
import serveNextAt from "next-electron-server";
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
addAllowOriginHeader,
|
||||
handleDownloads,
|
||||
handleExternalLinks,
|
||||
} from "./main/init";
|
||||
import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
|
||||
import log, { initLogging } from "./main/log";
|
||||
import { createApplicationMenu, createTrayContextMenu } from "./main/menu";
|
||||
|
@ -29,12 +26,12 @@ import { createWatcher } from "./main/services/watch";
|
|||
import { userPreferences } from "./main/stores/user-preferences";
|
||||
import { migrateLegacyWatchStoreIfNeeded } from "./main/stores/watch";
|
||||
import { registerStreamProtocol } from "./main/stream";
|
||||
import { isDev } from "./main/utils-electron";
|
||||
import { isDev } from "./main/utils/electron";
|
||||
|
||||
/**
|
||||
* The URL where the renderer HTML is being served from.
|
||||
*/
|
||||
export const rendererURL = "ente://app";
|
||||
const rendererURL = "ente://app";
|
||||
|
||||
/**
|
||||
* We want to hide our window instead of closing it when the user presses the
|
||||
|
@ -130,50 +127,18 @@ const registerPrivilegedSchemes = () => {
|
|||
{
|
||||
scheme: "stream",
|
||||
privileges: {
|
||||
// TODO(MR): Remove the commented bits if we don't end up
|
||||
// needing them by the time the IPC refactoring is done.
|
||||
|
||||
// Prevent the insecure origin issues when fetching this
|
||||
// secure: true,
|
||||
// Allow the web fetch API in the renderer to use this scheme.
|
||||
supportFetchAPI: true,
|
||||
// Allow it to be used with video tags.
|
||||
// stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* [Note: Increased disk cache for the desktop app]
|
||||
*
|
||||
* Set the "disk-cache-size" command line flag to ask the Chromium process to
|
||||
* use a larger size for the caches that it keeps on disk. This allows us to use
|
||||
* the web based caching mechanisms on both the web and the desktop app, just
|
||||
* ask the embedded Chromium to be a bit more generous in disk usage when
|
||||
* running as the desktop app.
|
||||
*
|
||||
* The size we provide is in bytes.
|
||||
* https://www.electronjs.org/docs/latest/api/command-line-switches#--disk-cache-sizesize
|
||||
*
|
||||
* Note that increasing the disk cache size does not guarantee that Chromium
|
||||
* will respect in verbatim, it uses its own heuristics atop this hint.
|
||||
* https://superuser.com/questions/378991/what-is-chrome-default-cache-size-limit/1577693#1577693
|
||||
*
|
||||
* See also: [Note: Caching files].
|
||||
*/
|
||||
const increaseDiskCache = () =>
|
||||
app.commandLine.appendSwitch(
|
||||
"disk-cache-size",
|
||||
`${5 * 1024 * 1024 * 1024}`, // 5 GB
|
||||
);
|
||||
|
||||
/**
|
||||
* Create an return the {@link BrowserWindow} that will form our app's UI.
|
||||
*
|
||||
* This window will show the HTML served from {@link rendererURL}.
|
||||
*/
|
||||
const createMainWindow = async () => {
|
||||
const createMainWindow = () => {
|
||||
// Create the main window. This'll show our web content.
|
||||
const window = new BrowserWindow({
|
||||
webPreferences: {
|
||||
|
@ -187,7 +152,7 @@ const createMainWindow = async () => {
|
|||
show: false,
|
||||
});
|
||||
|
||||
const wasAutoLaunched = await autoLauncher.wasAutoLaunched();
|
||||
const wasAutoLaunched = autoLauncher.wasAutoLaunched();
|
||||
if (wasAutoLaunched) {
|
||||
// Don't automatically show the app's window if we were auto-launched.
|
||||
// On macOS, also hide the dock icon on macOS.
|
||||
|
@ -201,7 +166,7 @@ const createMainWindow = async () => {
|
|||
if (isDev) window.webContents.openDevTools();
|
||||
|
||||
window.webContents.on("render-process-gone", (_, details) => {
|
||||
log.error(`render-process-gone: ${details}`);
|
||||
log.error(`render-process-gone: ${details.reason}`);
|
||||
window.webContents.reload();
|
||||
});
|
||||
|
||||
|
@ -209,7 +174,7 @@ const createMainWindow = async () => {
|
|||
// webContents is not responding to input messages for > 30 seconds."
|
||||
window.webContents.on("unresponsive", () => {
|
||||
log.error(
|
||||
"Main window's webContents are unresponsive, will restart the renderer process",
|
||||
"MainWindow's webContents are unresponsive, will restart the renderer process",
|
||||
);
|
||||
window.webContents.forcefullyCrashRenderer();
|
||||
});
|
||||
|
@ -230,7 +195,7 @@ const createMainWindow = async () => {
|
|||
});
|
||||
|
||||
window.on("show", () => {
|
||||
if (process.platform == "darwin") app.dock.show();
|
||||
if (process.platform == "darwin") void app.dock.show();
|
||||
});
|
||||
|
||||
// Let ipcRenderer know when mainWindow is in the foreground so that it can
|
||||
|
@ -240,6 +205,58 @@ const createMainWindow = async () => {
|
|||
return window;
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically set the save path for user initiated downloads to the system's
|
||||
* "downloads" directory instead of asking the user to select a save location.
|
||||
*/
|
||||
export const setDownloadPath = (webContents: WebContents) => {
|
||||
webContents.session.on("will-download", (_, item) => {
|
||||
item.setSavePath(
|
||||
uniqueSavePath(app.getPath("downloads"), item.getFilename()),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const uniqueSavePath = (dirPath: string, fileName: string) => {
|
||||
const { name, ext } = path.parse(fileName);
|
||||
|
||||
let savePath = path.join(dirPath, fileName);
|
||||
let n = 1;
|
||||
while (existsSync(savePath)) {
|
||||
const suffixedName = [`${name}(${n})`, ext].filter((x) => x).join(".");
|
||||
savePath = path.join(dirPath, suffixedName);
|
||||
n++;
|
||||
}
|
||||
return savePath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Allow opening external links, e.g. when the user clicks on the "Feature
|
||||
* requests" button in the sidebar (to open our GitHub repository), or when they
|
||||
* click the "Support" button to send an email to support.
|
||||
*
|
||||
* @param webContents The renderer to configure.
|
||||
*/
|
||||
export const allowExternalLinks = (webContents: WebContents) => {
|
||||
// By default, if the user were open a link, say
|
||||
// https://github.com/ente-io/ente/discussions, then it would open a _new_
|
||||
// BrowserWindow within our app.
|
||||
//
|
||||
// This is not the behaviour we want; what we want is to ask the system to
|
||||
// handle the link (e.g. open the URL in the default browser, or if it is a
|
||||
// mailto: link, then open the user's mail client).
|
||||
//
|
||||
// Returning `action` "deny" accomplishes this.
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (!url.startsWith(rendererURL)) {
|
||||
void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
} else {
|
||||
return { action: "allow" };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Add an icon for our app in the system tray.
|
||||
*
|
||||
|
@ -272,24 +289,24 @@ const setupTrayItem = (mainWindow: BrowserWindow) => {
|
|||
* Older versions of our app used to maintain a cache dir using the main
|
||||
* process. This has been deprecated in favor of using a normal web cache.
|
||||
*
|
||||
* See [Note: Increased disk cache for the desktop app]
|
||||
*
|
||||
* Delete the old cache dir if it exists. This code was added March 2024, and
|
||||
* can be removed after some time once most people have upgraded to newer
|
||||
* versions.
|
||||
*/
|
||||
const deleteLegacyDiskCacheDirIfExists = async () => {
|
||||
// The existing code was passing "cache" as a parameter to getPath. This is
|
||||
// incorrect if we go by the types - "cache" is not a valid value for the
|
||||
// parameter to `app.getPath`.
|
||||
// The existing code was passing "cache" as a parameter to getPath.
|
||||
//
|
||||
// It might be an issue in the types, since at runtime it seems to work. For
|
||||
// example, on macOS I get `~/Library/Caches`.
|
||||
// However, "cache" is not a valid parameter to getPath. It works! (for
|
||||
// example, on macOS I get `~/Library/Caches`), but it is intentionally not
|
||||
// documented as part of the public API:
|
||||
//
|
||||
// - docs: remove "cache" from app.getPath
|
||||
// https://github.com/electron/electron/pull/33509
|
||||
//
|
||||
// Irrespective, we replicate the original behaviour so that we get back the
|
||||
// same path that the old got was getting.
|
||||
// same path that the old code was getting.
|
||||
//
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error "cache" works but is not part of the public API.
|
||||
const cacheDir = path.join(app.getPath("cache"), "ente");
|
||||
if (existsSync(cacheDir)) {
|
||||
log.info(`Removing legacy disk cache from ${cacheDir}`);
|
||||
|
@ -326,7 +343,6 @@ const main = () => {
|
|||
// The order of the next two calls is important
|
||||
setupRendererServer();
|
||||
registerPrivilegedSchemes();
|
||||
increaseDiskCache();
|
||||
migrateLegacyWatchStoreIfNeeded();
|
||||
|
||||
app.on("second-instance", () => {
|
||||
|
@ -341,32 +357,35 @@ const main = () => {
|
|||
// Emitted once, when Electron has finished initializing.
|
||||
//
|
||||
// Note that some Electron APIs can only be used after this event occurs.
|
||||
app.on("ready", async () => {
|
||||
// Create window and prepare for renderer
|
||||
mainWindow = await createMainWindow();
|
||||
attachIPCHandlers();
|
||||
attachFSWatchIPCHandlers(createWatcher(mainWindow));
|
||||
registerStreamProtocol();
|
||||
handleDownloads(mainWindow);
|
||||
handleExternalLinks(mainWindow);
|
||||
addAllowOriginHeader(mainWindow);
|
||||
void app.whenReady().then(() => {
|
||||
void (async () => {
|
||||
// Create window and prepare for the renderer.
|
||||
mainWindow = createMainWindow();
|
||||
attachIPCHandlers();
|
||||
attachFSWatchIPCHandlers(createWatcher(mainWindow));
|
||||
registerStreamProtocol();
|
||||
|
||||
// Start loading the renderer
|
||||
mainWindow.loadURL(rendererURL);
|
||||
// Configure the renderer's environment.
|
||||
setDownloadPath(mainWindow.webContents);
|
||||
allowExternalLinks(mainWindow.webContents);
|
||||
|
||||
// Continue on with the rest of the startup sequence
|
||||
Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
|
||||
setupTrayItem(mainWindow);
|
||||
if (!isDev) setupAutoUpdater(mainWindow);
|
||||
// Start loading the renderer.
|
||||
void mainWindow.loadURL(rendererURL);
|
||||
|
||||
try {
|
||||
deleteLegacyDiskCacheDirIfExists();
|
||||
deleteLegacyKeysStoreIfExists();
|
||||
} catch (e) {
|
||||
// Log but otherwise ignore errors during non-critical startup
|
||||
// actions.
|
||||
log.error("Ignoring startup error", e);
|
||||
}
|
||||
// Continue on with the rest of the startup sequence.
|
||||
Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
|
||||
setupTrayItem(mainWindow);
|
||||
if (!isDev) setupAutoUpdater(mainWindow);
|
||||
|
||||
try {
|
||||
await deleteLegacyDiskCacheDirIfExists();
|
||||
await deleteLegacyKeysStoreIfExists();
|
||||
} catch (e) {
|
||||
// Log but otherwise ignore errors during non-critical startup
|
||||
// actions.
|
||||
log.error("Ignoring startup error", e);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// This is a macOS only event. Show our window when the user activates the
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
import { dialog } from "electron/main";
|
||||
import path from "node:path";
|
||||
import type { ElectronFile } from "../types/ipc";
|
||||
import { getDirFilePaths, getElectronFile } from "./services/fs";
|
||||
import { getElectronFilesFromGoogleZip } from "./services/upload";
|
||||
|
||||
export const selectDirectory = async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (result.filePaths && result.filePaths.length > 0) {
|
||||
return result.filePaths[0]?.split(path.sep)?.join(path.posix.sep);
|
||||
}
|
||||
};
|
||||
|
||||
export const showUploadFilesDialog = async () => {
|
||||
const selectedFiles = await dialog.showOpenDialog({
|
||||
properties: ["openFile", "multiSelections"],
|
||||
});
|
||||
const filePaths = selectedFiles.filePaths;
|
||||
return await Promise.all(filePaths.map(getElectronFile));
|
||||
};
|
||||
|
||||
export const showUploadDirsDialog = async () => {
|
||||
const dir = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", "multiSelections"],
|
||||
});
|
||||
|
||||
let filePaths: string[] = [];
|
||||
for (const dirPath of dir.filePaths) {
|
||||
filePaths = [...filePaths, ...(await getDirFilePaths(dirPath))];
|
||||
}
|
||||
|
||||
return await Promise.all(filePaths.map(getElectronFile));
|
||||
};
|
||||
|
||||
export const showUploadZipDialog = async () => {
|
||||
const selectedFiles = await dialog.showOpenDialog({
|
||||
properties: ["openFile", "multiSelections"],
|
||||
filters: [{ name: "Zip File", extensions: ["zip"] }],
|
||||
});
|
||||
const filePaths = selectedFiles.filePaths;
|
||||
|
||||
let files: ElectronFile[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
files = [...files, ...(await getElectronFilesFromGoogleZip(filePath))];
|
||||
}
|
||||
|
||||
return {
|
||||
zipPaths: filePaths,
|
||||
files,
|
||||
};
|
||||
};
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* @file file system related functions exposed over the context bridge.
|
||||
*/
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
export const fsExists = (path: string) => existsSync(path);
|
||||
|
||||
export const fsRename = (oldPath: string, newPath: string) =>
|
||||
fs.rename(oldPath, newPath);
|
||||
|
||||
export const fsMkdirIfNeeded = (dirPath: string) =>
|
||||
fs.mkdir(dirPath, { recursive: true });
|
||||
|
||||
export const fsRmdir = (path: string) => fs.rmdir(path);
|
||||
|
||||
export const fsRm = (path: string) => fs.rm(path);
|
||||
|
||||
export const fsReadTextFile = async (filePath: string) =>
|
||||
fs.readFile(filePath, "utf-8");
|
||||
|
||||
export const fsWriteFile = (path: string, contents: string) =>
|
||||
fs.writeFile(path, contents);
|
||||
|
||||
export const fsIsDir = async (dirPath: string) => {
|
||||
if (!existsSync(dirPath)) return false;
|
||||
const stat = await fs.stat(dirPath);
|
||||
return stat.isDirectory();
|
||||
};
|
||||
|
||||
export const fsSize = (path: string) => fs.stat(path).then((s) => s.size);
|
|
@ -1,63 +0,0 @@
|
|||
import { BrowserWindow, app, shell } from "electron";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { rendererURL } from "../main";
|
||||
|
||||
export function handleDownloads(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.session.on("will-download", (_, item) => {
|
||||
item.setSavePath(
|
||||
getUniqueSavePath(item.getFilename(), app.getPath("downloads")),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getUniqueSavePath(filename: string, directory: string): string {
|
||||
let uniqueFileSavePath = path.join(directory, filename);
|
||||
const { name: filenameWithoutExtension, ext: extension } =
|
||||
path.parse(filename);
|
||||
let n = 0;
|
||||
while (existsSync(uniqueFileSavePath)) {
|
||||
n++;
|
||||
// filter need to remove undefined extension from the array
|
||||
// else [`${fileName}`, undefined].join(".") will lead to `${fileName}.` as joined string
|
||||
const fileNameWithNumberedSuffix = [
|
||||
`${filenameWithoutExtension}(${n})`,
|
||||
extension,
|
||||
]
|
||||
.filter((x) => x) // filters out undefined/null values
|
||||
.join("");
|
||||
uniqueFileSavePath = path.join(directory, fileNameWithNumberedSuffix);
|
||||
}
|
||||
return uniqueFileSavePath;
|
||||
}
|
||||
|
||||
export function handleExternalLinks(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (!url.startsWith(rendererURL)) {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
} else {
|
||||
return { action: "allow" };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function addAllowOriginHeader(mainWindow: BrowserWindow) {
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
details.responseHeaders = lowerCaseHeaders(details.responseHeaders);
|
||||
details.responseHeaders["access-control-allow-origin"] = ["*"];
|
||||
callback({
|
||||
responseHeaders: details.responseHeaders,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function lowerCaseHeaders(responseHeaders: Record<string, string[]>) {
|
||||
const headers: Record<string, string[]> = {};
|
||||
for (const key of Object.keys(responseHeaders)) {
|
||||
headers[key.toLowerCase()] = responseHeaders[key];
|
||||
}
|
||||
return headers;
|
||||
}
|
|
@ -14,13 +14,21 @@ import type {
|
|||
CollectionMapping,
|
||||
FolderWatch,
|
||||
PendingUploads,
|
||||
ZipItem,
|
||||
} from "../types/ipc";
|
||||
import { logToDisk } from "./log";
|
||||
import {
|
||||
appVersion,
|
||||
skipAppUpdate,
|
||||
updateAndRestart,
|
||||
updateOnNextRestart,
|
||||
} from "./services/app-update";
|
||||
import {
|
||||
openDirectory,
|
||||
openLogDirectory,
|
||||
selectDirectory,
|
||||
showUploadDirsDialog,
|
||||
showUploadFilesDialog,
|
||||
showUploadZipDialog,
|
||||
} from "./dialogs";
|
||||
} from "./services/dir";
|
||||
import { ffmpegExec } from "./services/ffmpeg";
|
||||
import {
|
||||
fsExists,
|
||||
fsIsDir,
|
||||
|
@ -29,18 +37,8 @@ import {
|
|||
fsRename,
|
||||
fsRm,
|
||||
fsRmdir,
|
||||
fsSize,
|
||||
fsWriteFile,
|
||||
} from "./fs";
|
||||
import { logToDisk } from "./log";
|
||||
import {
|
||||
appVersion,
|
||||
skipAppUpdate,
|
||||
updateAndRestart,
|
||||
updateOnNextRestart,
|
||||
} from "./services/app-update";
|
||||
import { ffmpegExec } from "./services/ffmpeg";
|
||||
import { getDirFiles } from "./services/fs";
|
||||
} from "./services/fs";
|
||||
import { convertToJPEG, generateImageThumbnail } from "./services/image";
|
||||
import {
|
||||
clipImageEmbedding,
|
||||
|
@ -53,20 +51,23 @@ import {
|
|||
saveEncryptionKey,
|
||||
} from "./services/store";
|
||||
import {
|
||||
getElectronFilesFromGoogleZip,
|
||||
clearPendingUploads,
|
||||
listZipItems,
|
||||
markUploadedFiles,
|
||||
markUploadedZipItems,
|
||||
pathOrZipItemSize,
|
||||
pendingUploads,
|
||||
setPendingUploadCollection,
|
||||
setPendingUploadFiles,
|
||||
setPendingUploads,
|
||||
} from "./services/upload";
|
||||
import {
|
||||
watchAdd,
|
||||
watchFindFiles,
|
||||
watchGet,
|
||||
watchRemove,
|
||||
watchReset,
|
||||
watchUpdateIgnoredFiles,
|
||||
watchUpdateSyncedFiles,
|
||||
} from "./services/watch";
|
||||
import { openDirectory, openLogDirectory } from "./utils-electron";
|
||||
|
||||
/**
|
||||
* Listen for IPC events sent/invoked by the renderer process, and route them to
|
||||
|
@ -93,16 +94,20 @@ export const attachIPCHandlers = () => {
|
|||
|
||||
ipcMain.handle("appVersion", () => appVersion());
|
||||
|
||||
ipcMain.handle("openDirectory", (_, dirPath) => openDirectory(dirPath));
|
||||
ipcMain.handle("openDirectory", (_, dirPath: string) =>
|
||||
openDirectory(dirPath),
|
||||
);
|
||||
|
||||
ipcMain.handle("openLogDirectory", () => openLogDirectory());
|
||||
|
||||
// See [Note: Catching exception during .send/.on]
|
||||
ipcMain.on("logToDisk", (_, message) => logToDisk(message));
|
||||
ipcMain.on("logToDisk", (_, message: string) => logToDisk(message));
|
||||
|
||||
ipcMain.handle("selectDirectory", () => selectDirectory());
|
||||
|
||||
ipcMain.on("clearStores", () => clearStores());
|
||||
|
||||
ipcMain.handle("saveEncryptionKey", (_, encryptionKey) =>
|
||||
ipcMain.handle("saveEncryptionKey", (_, encryptionKey: string) =>
|
||||
saveEncryptionKey(encryptionKey),
|
||||
);
|
||||
|
||||
|
@ -112,21 +117,23 @@ export const attachIPCHandlers = () => {
|
|||
|
||||
ipcMain.on("updateAndRestart", () => updateAndRestart());
|
||||
|
||||
ipcMain.on("updateOnNextRestart", (_, version) =>
|
||||
ipcMain.on("updateOnNextRestart", (_, version: string) =>
|
||||
updateOnNextRestart(version),
|
||||
);
|
||||
|
||||
ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version));
|
||||
ipcMain.on("skipAppUpdate", (_, version: string) => skipAppUpdate(version));
|
||||
|
||||
// - FS
|
||||
|
||||
ipcMain.handle("fsExists", (_, path) => fsExists(path));
|
||||
ipcMain.handle("fsExists", (_, path: string) => fsExists(path));
|
||||
|
||||
ipcMain.handle("fsRename", (_, oldPath: string, newPath: string) =>
|
||||
fsRename(oldPath, newPath),
|
||||
);
|
||||
|
||||
ipcMain.handle("fsMkdirIfNeeded", (_, dirPath) => fsMkdirIfNeeded(dirPath));
|
||||
ipcMain.handle("fsMkdirIfNeeded", (_, dirPath: string) =>
|
||||
fsMkdirIfNeeded(dirPath),
|
||||
);
|
||||
|
||||
ipcMain.handle("fsRmdir", (_, path: string) => fsRmdir(path));
|
||||
|
||||
|
@ -140,8 +147,6 @@ export const attachIPCHandlers = () => {
|
|||
|
||||
ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath));
|
||||
|
||||
ipcMain.handle("fsSize", (_, path: string) => fsSize(path));
|
||||
|
||||
// - Conversion
|
||||
|
||||
ipcMain.handle("convertToJPEG", (_, imageData: Uint8Array) =>
|
||||
|
@ -152,10 +157,10 @@ export const attachIPCHandlers = () => {
|
|||
"generateImageThumbnail",
|
||||
(
|
||||
_,
|
||||
dataOrPath: Uint8Array | string,
|
||||
dataOrPathOrZipItem: Uint8Array | string | ZipItem,
|
||||
maxDimension: number,
|
||||
maxSize: number,
|
||||
) => generateImageThumbnail(dataOrPath, maxDimension, maxSize),
|
||||
) => generateImageThumbnail(dataOrPathOrZipItem, maxDimension, maxSize),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
|
@ -163,10 +168,16 @@ export const attachIPCHandlers = () => {
|
|||
(
|
||||
_,
|
||||
command: string[],
|
||||
dataOrPath: Uint8Array | string,
|
||||
dataOrPathOrZipItem: Uint8Array | string | ZipItem,
|
||||
outputFileExtension: string,
|
||||
timeoutMS: number,
|
||||
) => ffmpegExec(command, dataOrPath, outputFileExtension, timeoutMS),
|
||||
) =>
|
||||
ffmpegExec(
|
||||
command,
|
||||
dataOrPathOrZipItem,
|
||||
outputFileExtension,
|
||||
timeoutMS,
|
||||
),
|
||||
);
|
||||
|
||||
// - ML
|
||||
|
@ -187,37 +198,33 @@ export const attachIPCHandlers = () => {
|
|||
faceEmbedding(input),
|
||||
);
|
||||
|
||||
// - File selection
|
||||
|
||||
ipcMain.handle("selectDirectory", () => selectDirectory());
|
||||
|
||||
ipcMain.handle("showUploadFilesDialog", () => showUploadFilesDialog());
|
||||
|
||||
ipcMain.handle("showUploadDirsDialog", () => showUploadDirsDialog());
|
||||
|
||||
ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog());
|
||||
|
||||
// - Upload
|
||||
|
||||
ipcMain.handle("listZipItems", (_, zipPath: string) =>
|
||||
listZipItems(zipPath),
|
||||
);
|
||||
|
||||
ipcMain.handle("pathOrZipItemSize", (_, pathOrZipItem: string | ZipItem) =>
|
||||
pathOrZipItemSize(pathOrZipItem),
|
||||
);
|
||||
|
||||
ipcMain.handle("pendingUploads", () => pendingUploads());
|
||||
|
||||
ipcMain.handle("setPendingUploadCollection", (_, collectionName: string) =>
|
||||
setPendingUploadCollection(collectionName),
|
||||
ipcMain.handle("setPendingUploads", (_, pendingUploads: PendingUploads) =>
|
||||
setPendingUploads(pendingUploads),
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"setPendingUploadFiles",
|
||||
(_, type: PendingUploads["type"], filePaths: string[]) =>
|
||||
setPendingUploadFiles(type, filePaths),
|
||||
"markUploadedFiles",
|
||||
(_, paths: PendingUploads["filePaths"]) => markUploadedFiles(paths),
|
||||
);
|
||||
|
||||
// -
|
||||
|
||||
ipcMain.handle("getElectronFilesFromGoogleZip", (_, filePath: string) =>
|
||||
getElectronFilesFromGoogleZip(filePath),
|
||||
ipcMain.handle(
|
||||
"markUploadedZipItems",
|
||||
(_, items: PendingUploads["zipItems"]) => markUploadedZipItems(items),
|
||||
);
|
||||
|
||||
ipcMain.handle("getDirFiles", (_, dirPath: string) => getDirFiles(dirPath));
|
||||
ipcMain.handle("clearPendingUploads", () => clearPendingUploads());
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -257,4 +264,6 @@ export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
|
|||
ipcMain.handle("watchFindFiles", (_, folderPath: string) =>
|
||||
watchFindFiles(folderPath),
|
||||
);
|
||||
|
||||
ipcMain.handle("watchReset", () => watchReset(watcher));
|
||||
};
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import log from "electron-log";
|
||||
import util from "node:util";
|
||||
import { isDev } from "./utils-electron";
|
||||
import { isDev } from "./utils/electron";
|
||||
|
||||
/**
|
||||
* Initialize logging in the main process.
|
||||
*
|
||||
* This will set our underlying logger up to log to a file named `ente.log`,
|
||||
*
|
||||
* - on Linux at ~/.config/ente/logs/main.log
|
||||
* - on macOS at ~/Library/Logs/ente/main.log
|
||||
* - on Windows at %USERPROFILE%\AppData\Roaming\ente\logs\main.log
|
||||
* - on Linux at ~/.config/ente/logs/ente.log
|
||||
* - on macOS at ~/Library/Logs/ente/ente.log
|
||||
* - on Windows at %USERPROFILE%\AppData\Roaming\ente\logs\ente.log
|
||||
*
|
||||
* On dev builds, it will also log to the console.
|
||||
*/
|
||||
|
@ -65,7 +65,7 @@ const logError_ = (message: string) => {
|
|||
if (isDev) console.error(`[error] ${message}`);
|
||||
};
|
||||
|
||||
const logInfo = (...params: any[]) => {
|
||||
const logInfo = (...params: unknown[]) => {
|
||||
const message = params
|
||||
.map((p) => (typeof p == "string" ? p : util.inspect(p)))
|
||||
.join(" ");
|
||||
|
@ -73,7 +73,7 @@ const logInfo = (...params: any[]) => {
|
|||
if (isDev) console.log(`[info] ${message}`);
|
||||
};
|
||||
|
||||
const logDebug = (param: () => any) => {
|
||||
const logDebug = (param: () => unknown) => {
|
||||
if (isDev) {
|
||||
const p = param();
|
||||
console.log(`[debug] ${typeof p == "string" ? p : util.inspect(p)}`);
|
||||
|
|
|
@ -8,8 +8,9 @@ import {
|
|||
import { allowWindowClose } from "../main";
|
||||
import { forceCheckForAppUpdates } from "./services/app-update";
|
||||
import autoLauncher from "./services/auto-launcher";
|
||||
import { openLogDirectory } from "./services/dir";
|
||||
import { userPreferences } from "./stores/user-preferences";
|
||||
import { isDev, openLogDirectory } from "./utils-electron";
|
||||
import { isDev } from "./utils/electron";
|
||||
|
||||
/** Create and return the entries in the app's main menu bar */
|
||||
export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
|
||||
|
@ -18,7 +19,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
|
|||
// Whenever the menu is redrawn the current value of these variables is used
|
||||
// to set the checked state for the various settings checkboxes.
|
||||
let isAutoLaunchEnabled = await autoLauncher.isEnabled();
|
||||
let shouldHideDockIcon = userPreferences.get("hideDockIcon");
|
||||
let shouldHideDockIcon = !!userPreferences.get("hideDockIcon");
|
||||
|
||||
const macOSOnly = (options: MenuItemConstructorOptions[]) =>
|
||||
process.platform == "darwin" ? options : [];
|
||||
|
@ -29,12 +30,12 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
|
|||
const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
|
||||
|
||||
const handleViewChangelog = () =>
|
||||
shell.openExternal(
|
||||
void shell.openExternal(
|
||||
"https://github.com/ente-io/ente/blob/main/desktop/CHANGELOG.md",
|
||||
);
|
||||
|
||||
const toggleAutoLaunch = () => {
|
||||
autoLauncher.toggleAutoLaunch();
|
||||
void autoLauncher.toggleAutoLaunch();
|
||||
isAutoLaunchEnabled = !isAutoLaunchEnabled;
|
||||
};
|
||||
|
||||
|
@ -45,13 +46,15 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
|
|||
shouldHideDockIcon = !shouldHideDockIcon;
|
||||
};
|
||||
|
||||
const handleHelp = () => shell.openExternal("https://help.ente.io/photos/");
|
||||
const handleHelp = () =>
|
||||
void shell.openExternal("https://help.ente.io/photos/");
|
||||
|
||||
const handleSupport = () => shell.openExternal("mailto:support@ente.io");
|
||||
const handleSupport = () =>
|
||||
void shell.openExternal("mailto:support@ente.io");
|
||||
|
||||
const handleBlog = () => shell.openExternal("https://ente.io/blog/");
|
||||
const handleBlog = () => void shell.openExternal("https://ente.io/blog/");
|
||||
|
||||
const handleViewLogs = openLogDirectory;
|
||||
const handleViewLogs = () => void openLogDirectory();
|
||||
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
|
|
|
@ -12,8 +12,8 @@ export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
|
|||
autoUpdater.autoDownload = false;
|
||||
|
||||
const oneDay = 1 * 24 * 60 * 60 * 1000;
|
||||
setInterval(() => checkForUpdatesAndNotify(mainWindow), oneDay);
|
||||
checkForUpdatesAndNotify(mainWindow);
|
||||
setInterval(() => void checkForUpdatesAndNotify(mainWindow), oneDay);
|
||||
void checkForUpdatesAndNotify(mainWindow);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -22,7 +22,7 @@ export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
|
|||
export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => {
|
||||
userPreferences.delete("skipAppVersion");
|
||||
userPreferences.delete("muteUpdateNotificationVersion");
|
||||
checkForUpdatesAndNotify(mainWindow);
|
||||
void checkForUpdatesAndNotify(mainWindow);
|
||||
};
|
||||
|
||||
const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
|
||||
|
@ -36,18 +36,21 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
|
|||
|
||||
log.debug(() => `Update check found version ${version}`);
|
||||
|
||||
if (!version)
|
||||
throw new Error("Unexpected empty version obtained from auto-updater");
|
||||
|
||||
if (compareVersions(version, app.getVersion()) <= 0) {
|
||||
log.debug(() => "Skipping update, already at latest version");
|
||||
return;
|
||||
}
|
||||
|
||||
if (version === userPreferences.get("skipAppVersion")) {
|
||||
if (version == userPreferences.get("skipAppVersion")) {
|
||||
log.info(`User chose to skip version ${version}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mutedVersion = userPreferences.get("muteUpdateNotificationVersion");
|
||||
if (version === mutedVersion) {
|
||||
if (version == mutedVersion) {
|
||||
log.info(`User has muted update notifications for version ${version}`);
|
||||
return;
|
||||
}
|
||||
|
@ -56,7 +59,7 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
|
|||
mainWindow.webContents.send("appUpdateAvailable", update);
|
||||
|
||||
log.debug(() => "Attempting auto update");
|
||||
autoUpdater.downloadUpdate();
|
||||
await autoUpdater.downloadUpdate();
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
|
|
@ -38,7 +38,7 @@ class AutoLauncher {
|
|||
}
|
||||
}
|
||||
|
||||
async wasAutoLaunched() {
|
||||
wasAutoLaunched() {
|
||||
if (this.autoLaunch) {
|
||||
return app.commandLine.hasSwitch("hidden");
|
||||
} else {
|
||||
|
|
51
desktop/src/main/services/dir.ts
Normal file
51
desktop/src/main/services/dir.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { shell } from "electron/common";
|
||||
import { app, dialog } from "electron/main";
|
||||
import path from "node:path";
|
||||
import { posixPath } from "../utils/electron";
|
||||
|
||||
export const selectDirectory = async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
const dirPath = result.filePaths[0];
|
||||
return dirPath ? posixPath(dirPath) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the given {@link dirPath} in the system's folder viewer.
|
||||
*
|
||||
* For example, on macOS this'll open {@link dirPath} in Finder.
|
||||
*/
|
||||
export const openDirectory = async (dirPath: string) => {
|
||||
// We need to use `path.normalize` because `shell.openPath; does not support
|
||||
// POSIX path, it needs to be a platform specific path:
|
||||
// https://github.com/electron/electron/issues/28831#issuecomment-826370589
|
||||
const res = await shell.openPath(path.normalize(dirPath));
|
||||
// `shell.openPath` resolves with a string containing the error message
|
||||
// corresponding to the failure if a failure occurred, otherwise "".
|
||||
if (res) throw new Error(`Failed to open directory ${dirPath}: res`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the app's log directory in the system's folder viewer.
|
||||
*
|
||||
* @see {@link openDirectory}
|
||||
*/
|
||||
export const openLogDirectory = () => openDirectory(logDirectoryPath());
|
||||
|
||||
/**
|
||||
* Return the path where the logs for the app are saved.
|
||||
*
|
||||
* [Note: Electron app paths]
|
||||
*
|
||||
* By default, these paths are at the following locations:
|
||||
*
|
||||
* - macOS: `~/Library/Application Support/ente`
|
||||
* - Linux: `~/.config/ente`
|
||||
* - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local\ente`
|
||||
* - Windows: C:\Users\<you>\AppData\Local\<Your App Name>
|
||||
*
|
||||
* https://www.electronjs.org/docs/latest/api/app
|
||||
*
|
||||
*/
|
||||
const logDirectoryPath = () => app.getPath("logs");
|
|
@ -1,9 +1,14 @@
|
|||
import pathToFfmpeg from "ffmpeg-static";
|
||||
import fs from "node:fs/promises";
|
||||
import type { ZipItem } from "../../types/ipc";
|
||||
import log from "../log";
|
||||
import { withTimeout } from "../utils";
|
||||
import { execAsync } from "../utils-electron";
|
||||
import { deleteTempFile, makeTempFilePath } from "../utils-temp";
|
||||
import { ensure, withTimeout } from "../utils/common";
|
||||
import { execAsync } from "../utils/electron";
|
||||
import {
|
||||
deleteTempFile,
|
||||
makeFileForDataOrPathOrZipItem,
|
||||
makeTempFilePath,
|
||||
} from "../utils/temp";
|
||||
|
||||
/* Duplicated in the web app's code (used by the WASM FFmpeg implementation). */
|
||||
const ffmpegPathPlaceholder = "FFMPEG";
|
||||
|
@ -39,28 +44,24 @@ const outputPathPlaceholder = "OUTPUT";
|
|||
*/
|
||||
export const ffmpegExec = async (
|
||||
command: string[],
|
||||
dataOrPath: Uint8Array | string,
|
||||
dataOrPathOrZipItem: Uint8Array | string | ZipItem,
|
||||
outputFileExtension: string,
|
||||
timeoutMS: number,
|
||||
): Promise<Uint8Array> => {
|
||||
// TODO (MR): This currently copies files for both input and output. This
|
||||
// needs to be tested extremely large video files when invoked downstream of
|
||||
// `convertToMP4` in the web code.
|
||||
// TODO (MR): This currently copies files for both input (when
|
||||
// dataOrPathOrZipItem is data) and output. This needs to be tested
|
||||
// extremely large video files when invoked downstream of `convertToMP4` in
|
||||
// the web code.
|
||||
|
||||
let inputFilePath: string;
|
||||
let isInputFileTemporary: boolean;
|
||||
if (dataOrPath instanceof Uint8Array) {
|
||||
inputFilePath = await makeTempFilePath();
|
||||
isInputFileTemporary = true;
|
||||
} else {
|
||||
inputFilePath = dataOrPath;
|
||||
isInputFileTemporary = false;
|
||||
}
|
||||
const {
|
||||
path: inputFilePath,
|
||||
isFileTemporary: isInputFileTemporary,
|
||||
writeToTemporaryFile: writeToTemporaryInputFile,
|
||||
} = await makeFileForDataOrPathOrZipItem(dataOrPathOrZipItem);
|
||||
|
||||
const outputFilePath = await makeTempFilePath(outputFileExtension);
|
||||
try {
|
||||
if (dataOrPath instanceof Uint8Array)
|
||||
await fs.writeFile(inputFilePath, dataOrPath);
|
||||
await writeToTemporaryInputFile();
|
||||
|
||||
const cmd = substitutePlaceholders(
|
||||
command,
|
||||
|
@ -109,5 +110,5 @@ const ffmpegBinaryPath = () => {
|
|||
// This substitution of app.asar by app.asar.unpacked is suggested by the
|
||||
// ffmpeg-static library author themselves:
|
||||
// https://github.com/eugeneware/ffmpeg-static/issues/16
|
||||
return pathToFfmpeg.replace("app.asar", "app.asar.unpacked");
|
||||
return ensure(pathToFfmpeg).replace("app.asar", "app.asar.unpacked");
|
||||
};
|
||||
|
|
|
@ -1,177 +1,30 @@
|
|||
import StreamZip from "node-stream-zip";
|
||||
/**
|
||||
* @file file system related functions exposed over the context bridge.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ElectronFile } from "../../types/ipc";
|
||||
import log from "../log";
|
||||
|
||||
const FILE_STREAM_CHUNK_SIZE: number = 4 * 1024 * 1024;
|
||||
export const fsExists = (path: string) => existsSync(path);
|
||||
|
||||
export async function getDirFiles(dirPath: string) {
|
||||
const files = await getDirFilePaths(dirPath);
|
||||
const electronFiles = await Promise.all(files.map(getElectronFile));
|
||||
return electronFiles;
|
||||
}
|
||||
export const fsRename = (oldPath: string, newPath: string) =>
|
||||
fs.rename(oldPath, newPath);
|
||||
|
||||
// https://stackoverflow.com/a/63111390
|
||||
export const getDirFilePaths = async (dirPath: string) => {
|
||||
if (!(await fs.stat(dirPath)).isDirectory()) {
|
||||
return [dirPath];
|
||||
}
|
||||
export const fsMkdirIfNeeded = (dirPath: string) =>
|
||||
fs.mkdir(dirPath, { recursive: true });
|
||||
|
||||
let files: string[] = [];
|
||||
const filePaths = await fs.readdir(dirPath);
|
||||
export const fsRmdir = (path: string) => fs.rmdir(path);
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const absolute = path.join(dirPath, filePath);
|
||||
files = [...files, ...(await getDirFilePaths(absolute))];
|
||||
}
|
||||
export const fsRm = (path: string) => fs.rm(path);
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const getFileStream = async (filePath: string) => {
|
||||
const file = await fs.open(filePath, "r");
|
||||
let offset = 0;
|
||||
const readableStream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const buff = new Uint8Array(FILE_STREAM_CHUNK_SIZE);
|
||||
const bytesRead = (await file.read(
|
||||
buff,
|
||||
0,
|
||||
FILE_STREAM_CHUNK_SIZE,
|
||||
offset,
|
||||
)) as unknown as number;
|
||||
offset += bytesRead;
|
||||
if (bytesRead === 0) {
|
||||
controller.close();
|
||||
await file.close();
|
||||
} else {
|
||||
controller.enqueue(buff.slice(0, bytesRead));
|
||||
}
|
||||
} catch (e) {
|
||||
await file.close();
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
await file.close();
|
||||
},
|
||||
});
|
||||
return readableStream;
|
||||
};
|
||||
|
||||
export 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 getZipFileStream = async (
|
||||
zip: StreamZip.StreamZipAsync,
|
||||
filePath: string,
|
||||
) => {
|
||||
const stream = await zip.stream(filePath);
|
||||
const done = {
|
||||
current: false,
|
||||
};
|
||||
const inProgress = {
|
||||
current: false,
|
||||
};
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let resolveObj: (value?: any) => void = null;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let rejectObj: (reason?: any) => void = null;
|
||||
stream.on("readable", () => {
|
||||
try {
|
||||
if (resolveObj) {
|
||||
inProgress.current = true;
|
||||
const chunk = stream.read(FILE_STREAM_CHUNK_SIZE) as Buffer;
|
||||
if (chunk) {
|
||||
resolveObj(new Uint8Array(chunk));
|
||||
resolveObj = null;
|
||||
}
|
||||
inProgress.current = false;
|
||||
}
|
||||
} catch (e) {
|
||||
rejectObj(e);
|
||||
}
|
||||
});
|
||||
stream.on("end", () => {
|
||||
try {
|
||||
done.current = true;
|
||||
if (resolveObj && !inProgress.current) {
|
||||
resolveObj(null);
|
||||
resolveObj = null;
|
||||
}
|
||||
} catch (e) {
|
||||
rejectObj(e);
|
||||
}
|
||||
});
|
||||
stream.on("error", (e) => {
|
||||
try {
|
||||
done.current = true;
|
||||
if (rejectObj) {
|
||||
rejectObj(e);
|
||||
rejectObj = null;
|
||||
}
|
||||
} catch (e) {
|
||||
rejectObj(e);
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
log.error("Failed to pull from readableStream", e);
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return readableStream;
|
||||
export const fsReadTextFile = async (filePath: string) =>
|
||||
fs.readFile(filePath, "utf-8");
|
||||
|
||||
export const fsWriteFile = (path: string, contents: string) =>
|
||||
fs.writeFile(path, contents);
|
||||
|
||||
export const fsIsDir = async (dirPath: string) => {
|
||||
if (!existsSync(dirPath)) return false;
|
||||
const stat = await fs.stat(dirPath);
|
||||
return stat.isDirectory();
|
||||
};
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
/** @file Image format conversions and thumbnail generation */
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import { CustomErrorMessage } from "../../types/ipc";
|
||||
import path from "node:path";
|
||||
import { CustomErrorMessage, type ZipItem } from "../../types/ipc";
|
||||
import log from "../log";
|
||||
import { execAsync, isDev } from "../utils-electron";
|
||||
import { deleteTempFile, makeTempFilePath } from "../utils-temp";
|
||||
import { execAsync, isDev } from "../utils/electron";
|
||||
import {
|
||||
deleteTempFile,
|
||||
makeFileForDataOrPathOrZipItem,
|
||||
makeTempFilePath,
|
||||
} from "../utils/temp";
|
||||
|
||||
export const convertToJPEG = async (imageData: Uint8Array) => {
|
||||
const inputFilePath = await makeTempFilePath();
|
||||
|
@ -63,19 +67,15 @@ const imageMagickPath = () =>
|
|||
path.join(isDev ? "build" : process.resourcesPath, "image-magick");
|
||||
|
||||
export const generateImageThumbnail = async (
|
||||
dataOrPath: Uint8Array | string,
|
||||
dataOrPathOrZipItem: Uint8Array | string | ZipItem,
|
||||
maxDimension: number,
|
||||
maxSize: number,
|
||||
): Promise<Uint8Array> => {
|
||||
let inputFilePath: string;
|
||||
let isInputFileTemporary: boolean;
|
||||
if (dataOrPath instanceof Uint8Array) {
|
||||
inputFilePath = await makeTempFilePath();
|
||||
isInputFileTemporary = true;
|
||||
} else {
|
||||
inputFilePath = dataOrPath;
|
||||
isInputFileTemporary = false;
|
||||
}
|
||||
const {
|
||||
path: inputFilePath,
|
||||
isFileTemporary: isInputFileTemporary,
|
||||
writeToTemporaryFile: writeToTemporaryInputFile,
|
||||
} = await makeFileForDataOrPathOrZipItem(dataOrPathOrZipItem);
|
||||
|
||||
const outputFilePath = await makeTempFilePath("jpeg");
|
||||
|
||||
|
@ -89,8 +89,7 @@ export const generateImageThumbnail = async (
|
|||
);
|
||||
|
||||
try {
|
||||
if (dataOrPath instanceof Uint8Array)
|
||||
await fs.writeFile(inputFilePath, dataOrPath);
|
||||
await writeToTemporaryInputFile();
|
||||
|
||||
let thumbnail: Uint8Array;
|
||||
do {
|
||||
|
|
|
@ -11,7 +11,8 @@ import * as ort from "onnxruntime-node";
|
|||
import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
|
||||
import log from "../log";
|
||||
import { writeStream } from "../stream";
|
||||
import { deleteTempFile, makeTempFilePath } from "../utils-temp";
|
||||
import { ensure } from "../utils/common";
|
||||
import { deleteTempFile, makeTempFilePath } from "../utils/temp";
|
||||
import { makeCachedInferenceSession } from "./ml";
|
||||
|
||||
const cachedCLIPImageSession = makeCachedInferenceSession(
|
||||
|
@ -22,7 +23,7 @@ const cachedCLIPImageSession = makeCachedInferenceSession(
|
|||
export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
|
||||
const tempFilePath = await makeTempFilePath();
|
||||
const imageStream = new Response(jpegImageData.buffer).body;
|
||||
await writeStream(tempFilePath, imageStream);
|
||||
await writeStream(tempFilePath, ensure(imageStream));
|
||||
try {
|
||||
return await clipImageEmbedding_(tempFilePath);
|
||||
} finally {
|
||||
|
@ -44,30 +45,30 @@ const clipImageEmbedding_ = async (jpegFilePath: string) => {
|
|||
`onnx/clip image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
|
||||
);
|
||||
/* Need these model specific casts to type the result */
|
||||
const imageEmbedding = results["output"].data as Float32Array;
|
||||
const imageEmbedding = ensure(results.output).data as Float32Array;
|
||||
return normalizeEmbedding(imageEmbedding);
|
||||
};
|
||||
|
||||
const getRGBData = async (jpegFilePath: string) => {
|
||||
const getRGBData = async (jpegFilePath: string): Promise<number[]> => {
|
||||
const jpegData = await fs.readFile(jpegFilePath);
|
||||
const rawImageData = jpeg.decode(jpegData, {
|
||||
useTArray: true,
|
||||
formatAsRGBA: false,
|
||||
});
|
||||
|
||||
const nx: number = rawImageData.width;
|
||||
const ny: number = rawImageData.height;
|
||||
const inputImage: Uint8Array = rawImageData.data;
|
||||
const nx = rawImageData.width;
|
||||
const ny = rawImageData.height;
|
||||
const inputImage = rawImageData.data;
|
||||
|
||||
const nx2: number = 224;
|
||||
const ny2: number = 224;
|
||||
const totalSize: number = 3 * nx2 * ny2;
|
||||
const nx2 = 224;
|
||||
const ny2 = 224;
|
||||
const totalSize = 3 * nx2 * ny2;
|
||||
|
||||
const result: number[] = Array(totalSize).fill(0);
|
||||
const scale: number = Math.max(nx, ny) / 224;
|
||||
const result = Array<number>(totalSize).fill(0);
|
||||
const scale = Math.max(nx, ny) / 224;
|
||||
|
||||
const nx3: number = Math.round(nx / scale);
|
||||
const ny3: number = Math.round(ny / scale);
|
||||
const nx3 = Math.round(nx / scale);
|
||||
const ny3 = Math.round(ny / scale);
|
||||
|
||||
const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
|
||||
const std: number[] = [0.26862954, 0.26130258, 0.27577711];
|
||||
|
@ -76,40 +77,40 @@ const getRGBData = async (jpegFilePath: string) => {
|
|||
for (let x = 0; x < nx3; x++) {
|
||||
for (let 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 sx = (x + 0.5) * scale - 0.5;
|
||||
const sy = (y + 0.5) * scale - 0.5;
|
||||
|
||||
const x0: number = Math.max(0, Math.floor(sx));
|
||||
const y0: number = Math.max(0, Math.floor(sy));
|
||||
const x0 = Math.max(0, Math.floor(sx));
|
||||
const y0 = Math.max(0, Math.floor(sy));
|
||||
|
||||
const x1: number = Math.min(x0 + 1, nx - 1);
|
||||
const y1: number = Math.min(y0 + 1, ny - 1);
|
||||
const x1 = Math.min(x0 + 1, nx - 1);
|
||||
const y1 = Math.min(y0 + 1, ny - 1);
|
||||
|
||||
const dx: number = sx - x0;
|
||||
const dy: number = sy - y0;
|
||||
const dx = sx - x0;
|
||||
const dy = sy - y0;
|
||||
|
||||
const j00: number = 3 * (y0 * nx + x0) + c;
|
||||
const j01: number = 3 * (y0 * nx + x1) + c;
|
||||
const j10: number = 3 * (y1 * nx + x0) + c;
|
||||
const j11: number = 3 * (y1 * nx + x1) + c;
|
||||
const j00 = 3 * (y0 * nx + x0) + c;
|
||||
const j01 = 3 * (y0 * nx + x1) + c;
|
||||
const j10 = 3 * (y1 * nx + x0) + c;
|
||||
const j11 = 3 * (y1 * nx + x1) + c;
|
||||
|
||||
const v00: number = inputImage[j00];
|
||||
const v01: number = inputImage[j01];
|
||||
const v10: number = inputImage[j10];
|
||||
const v11: number = inputImage[j11];
|
||||
const v00 = inputImage[j00] ?? 0;
|
||||
const v01 = inputImage[j01] ?? 0;
|
||||
const v10 = inputImage[j10] ?? 0;
|
||||
const v11 = inputImage[j11] ?? 0;
|
||||
|
||||
const v0: number = v00 * (1 - dx) + v01 * dx;
|
||||
const v1: number = v10 * (1 - dx) + v11 * dx;
|
||||
const v0 = v00 * (1 - dx) + v01 * dx;
|
||||
const v1 = v10 * (1 - dx) + v11 * dx;
|
||||
|
||||
const v: number = v0 * (1 - dy) + v1 * dy;
|
||||
const v = v0 * (1 - dy) + v1 * dy;
|
||||
|
||||
const v2: number = Math.min(Math.max(Math.round(v), 0), 255);
|
||||
const v2 = Math.min(Math.max(Math.round(v), 0), 255);
|
||||
|
||||
// createTensorWithDataList is dumb compared to reshape and
|
||||
// hence has to be given with one channel after another
|
||||
const i: number = y * nx3 + x + (c % 3) * 224 * 224;
|
||||
const i = y * nx3 + x + (c % 3) * 224 * 224;
|
||||
|
||||
result[i] = (v2 / 255 - mean[c]) / std[c];
|
||||
result[i] = (v2 / 255 - (mean[c] ?? 0)) / (std[c] ?? 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -119,13 +120,12 @@ const getRGBData = async (jpegFilePath: string) => {
|
|||
|
||||
const normalizeEmbedding = (embedding: Float32Array) => {
|
||||
let normalization = 0;
|
||||
for (let index = 0; index < embedding.length; index++) {
|
||||
normalization += embedding[index] * embedding[index];
|
||||
}
|
||||
for (const v of embedding) normalization += v * v;
|
||||
|
||||
const sqrtNormalization = Math.sqrt(normalization);
|
||||
for (let index = 0; index < embedding.length; index++) {
|
||||
embedding[index] = embedding[index] / sqrtNormalization;
|
||||
}
|
||||
for (let index = 0; index < embedding.length; index++)
|
||||
embedding[index] = ensure(embedding[index]) / sqrtNormalization;
|
||||
|
||||
return embedding;
|
||||
};
|
||||
|
||||
|
@ -134,11 +134,9 @@ const cachedCLIPTextSession = makeCachedInferenceSession(
|
|||
64173509 /* 61.2 MB */,
|
||||
);
|
||||
|
||||
let _tokenizer: Tokenizer = null;
|
||||
let _tokenizer: Tokenizer | undefined;
|
||||
const getTokenizer = () => {
|
||||
if (!_tokenizer) {
|
||||
_tokenizer = new Tokenizer();
|
||||
}
|
||||
if (!_tokenizer) _tokenizer = new Tokenizer();
|
||||
return _tokenizer;
|
||||
};
|
||||
|
||||
|
@ -150,7 +148,7 @@ export const clipTextEmbeddingIfAvailable = async (text: string) => {
|
|||
|
||||
// Don't wait for the download to complete
|
||||
if (typeof sessionOrStatus == "string") {
|
||||
console.log(
|
||||
log.info(
|
||||
"Ignoring CLIP text embedding request because model download is pending",
|
||||
);
|
||||
return undefined;
|
||||
|
@ -169,6 +167,6 @@ export const clipTextEmbeddingIfAvailable = async (text: string) => {
|
|||
() =>
|
||||
`onnx/clip text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
|
||||
);
|
||||
const textEmbedding = results["output"].data as Float32Array;
|
||||
const textEmbedding = ensure(results.output).data as Float32Array;
|
||||
return normalizeEmbedding(textEmbedding);
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
import * as ort from "onnxruntime-node";
|
||||
import log from "../log";
|
||||
import { ensure } from "../utils/common";
|
||||
import { makeCachedInferenceSession } from "./ml";
|
||||
|
||||
const cachedFaceDetectionSession = makeCachedInferenceSession(
|
||||
|
@ -23,7 +24,7 @@ export const detectFaces = async (input: Float32Array) => {
|
|||
};
|
||||
const results = await session.run(feeds);
|
||||
log.debug(() => `onnx/yolo face detection took ${Date.now() - t} ms`);
|
||||
return results["output"].data;
|
||||
return ensure(results.output).data;
|
||||
};
|
||||
|
||||
const cachedFaceEmbeddingSession = makeCachedInferenceSession(
|
||||
|
@ -46,5 +47,6 @@ export const faceEmbedding = async (input: Float32Array) => {
|
|||
const results = await session.run(feeds);
|
||||
log.debug(() => `onnx/yolo face embedding took ${Date.now() - t} ms`);
|
||||
/* Need these model specific casts to extract and type the result */
|
||||
return (results.embeddings as unknown as any)["cpuData"] as Float32Array;
|
||||
return (results.embeddings as unknown as Record<string, unknown>)
|
||||
.cpuData as Float32Array;
|
||||
};
|
||||
|
|
|
@ -34,6 +34,7 @@ import { writeStream } from "../stream";
|
|||
* actively trigger a download until the returned function is called.
|
||||
*
|
||||
* @param modelName The name of the model to download.
|
||||
*
|
||||
* @param modelByteSize The size in bytes that we expect the model to have. If
|
||||
* the size of the downloaded model does not match the expected size, then we
|
||||
* will redownload it.
|
||||
|
@ -99,13 +100,15 @@ const downloadModel = async (saveLocation: string, name: string) => {
|
|||
// `mkdir -p` the directory where we want to save the model.
|
||||
const saveDir = path.dirname(saveLocation);
|
||||
await fs.mkdir(saveDir, { recursive: true });
|
||||
// Download
|
||||
// Download.
|
||||
log.info(`Downloading ML model from ${name}`);
|
||||
const url = `https://models.ente.io/${name}`;
|
||||
const res = await net.fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
|
||||
// Save
|
||||
await writeStream(saveLocation, res.body);
|
||||
const body = res.body;
|
||||
if (!body) throw new Error(`Received an null response for ${url}`);
|
||||
// Save.
|
||||
await writeStream(saveLocation, body);
|
||||
log.info(`Downloaded CLIP model ${name}`);
|
||||
};
|
||||
|
||||
|
@ -114,9 +117,9 @@ const downloadModel = async (saveLocation: string, name: string) => {
|
|||
*/
|
||||
const createInferenceSession = async (modelPath: string) => {
|
||||
return await ort.InferenceSession.create(modelPath, {
|
||||
// Restrict the number of threads to 1
|
||||
// Restrict the number of threads to 1.
|
||||
intraOpNumThreads: 1,
|
||||
// Be more conservative with RAM usage
|
||||
// Be more conservative with RAM usage.
|
||||
enableCpuMemArena: false,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -9,20 +9,20 @@ import { watchStore } from "../stores/watch";
|
|||
* This is useful to reset state when the user logs out.
|
||||
*/
|
||||
export const clearStores = () => {
|
||||
uploadStatusStore.clear();
|
||||
safeStorageStore.clear();
|
||||
uploadStatusStore.clear();
|
||||
watchStore.clear();
|
||||
};
|
||||
|
||||
export const saveEncryptionKey = async (encryptionKey: string) => {
|
||||
const encryptedKey: Buffer = await safeStorage.encryptString(encryptionKey);
|
||||
export const saveEncryptionKey = (encryptionKey: string) => {
|
||||
const encryptedKey = safeStorage.encryptString(encryptionKey);
|
||||
const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
|
||||
safeStorageStore.set("encryptionKey", b64EncryptedKey);
|
||||
};
|
||||
|
||||
export const encryptionKey = async (): Promise<string | undefined> => {
|
||||
export const encryptionKey = (): string | undefined => {
|
||||
const b64EncryptedKey = safeStorageStore.get("encryptionKey");
|
||||
if (!b64EncryptedKey) return undefined;
|
||||
const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
|
||||
return await safeStorage.decryptString(keyBuffer);
|
||||
return safeStorage.decryptString(keyBuffer);
|
||||
};
|
||||
|
|
|
@ -1,116 +1,149 @@
|
|||
import StreamZip from "node-stream-zip";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { existsSync } from "original-fs";
|
||||
import path from "path";
|
||||
import { ElectronFile, type PendingUploads } from "../../types/ipc";
|
||||
import {
|
||||
uploadStatusStore,
|
||||
type UploadStatusStore,
|
||||
} from "../stores/upload-status";
|
||||
import { getElectronFile, getZipFileStream } from "./fs";
|
||||
import type { PendingUploads, ZipItem } from "../../types/ipc";
|
||||
import { uploadStatusStore } from "../stores/upload-status";
|
||||
|
||||
export const pendingUploads = async () => {
|
||||
const collectionName = uploadStatusStore.get("collectionName");
|
||||
const filePaths = validSavedPaths("files");
|
||||
const zipPaths = validSavedPaths("zips");
|
||||
|
||||
let files: ElectronFile[] = [];
|
||||
let type: PendingUploads["type"];
|
||||
|
||||
if (zipPaths.length) {
|
||||
type = "zips";
|
||||
for (const zipPath of zipPaths) {
|
||||
files = [
|
||||
...files,
|
||||
...(await getElectronFilesFromGoogleZip(zipPath)),
|
||||
];
|
||||
}
|
||||
const pendingFilePaths = new Set(filePaths);
|
||||
files = files.filter((file) => pendingFilePaths.has(file.path));
|
||||
} else if (filePaths.length) {
|
||||
type = "files";
|
||||
files = await Promise.all(filePaths.map(getElectronFile));
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
collectionName,
|
||||
type,
|
||||
};
|
||||
};
|
||||
|
||||
export const validSavedPaths = (type: PendingUploads["type"]) => {
|
||||
const key = storeKey(type);
|
||||
const savedPaths = (uploadStatusStore.get(key) as string[]) ?? [];
|
||||
const paths = savedPaths.filter((p) => existsSync(p));
|
||||
uploadStatusStore.set(key, paths);
|
||||
return paths;
|
||||
};
|
||||
|
||||
export const setPendingUploadCollection = (collectionName: string) => {
|
||||
if (collectionName) uploadStatusStore.set("collectionName", collectionName);
|
||||
else uploadStatusStore.delete("collectionName");
|
||||
};
|
||||
|
||||
export const setPendingUploadFiles = (
|
||||
type: PendingUploads["type"],
|
||||
filePaths: string[],
|
||||
) => {
|
||||
const key = storeKey(type);
|
||||
if (filePaths) uploadStatusStore.set(key, filePaths);
|
||||
else uploadStatusStore.delete(key);
|
||||
};
|
||||
|
||||
const storeKey = (type: PendingUploads["type"]): keyof UploadStatusStore => {
|
||||
switch (type) {
|
||||
case "zips":
|
||||
return "zipPaths";
|
||||
case "files":
|
||||
return "filePaths";
|
||||
}
|
||||
};
|
||||
|
||||
export const getElectronFilesFromGoogleZip = async (filePath: string) => {
|
||||
const zip = new StreamZip.async({
|
||||
file: filePath,
|
||||
});
|
||||
const zipName = path.basename(filePath, ".zip");
|
||||
export const listZipItems = async (zipPath: string): Promise<ZipItem[]> => {
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
|
||||
const entries = await zip.entries();
|
||||
const files: ElectronFile[] = [];
|
||||
const entryNames: string[] = [];
|
||||
|
||||
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));
|
||||
// Ignore "hidden" files (files whose names begins with a dot).
|
||||
if (entry.isFile && !basename.startsWith(".")) {
|
||||
// `entry.name` is the path within the zip.
|
||||
entryNames.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
await zip.close();
|
||||
|
||||
return entryNames.map((entryName) => [zipPath, entryName]);
|
||||
};
|
||||
|
||||
export async function getZipEntryAsElectronFile(
|
||||
zipName: string,
|
||||
zip: StreamZip.StreamZipAsync,
|
||||
entry: StreamZip.ZipEntry,
|
||||
): Promise<ElectronFile> {
|
||||
export const pathOrZipItemSize = async (
|
||||
pathOrZipItem: string | ZipItem,
|
||||
): Promise<number> => {
|
||||
if (typeof pathOrZipItem == "string") {
|
||||
const stat = await fs.stat(pathOrZipItem);
|
||||
return stat.size;
|
||||
} else {
|
||||
const [zipPath, entryName] = pathOrZipItem;
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
const entry = await zip.entry(entryName);
|
||||
if (!entry)
|
||||
throw new Error(
|
||||
`An entry with name ${entryName} does not exist in the zip file at ${zipPath}`,
|
||||
);
|
||||
const size = entry.size;
|
||||
await zip.close();
|
||||
return size;
|
||||
}
|
||||
};
|
||||
|
||||
export const pendingUploads = async (): Promise<PendingUploads | undefined> => {
|
||||
const collectionName = uploadStatusStore.get("collectionName") ?? undefined;
|
||||
|
||||
const allFilePaths = uploadStatusStore.get("filePaths") ?? [];
|
||||
const filePaths = allFilePaths.filter((f) => existsSync(f));
|
||||
|
||||
const allZipItems = uploadStatusStore.get("zipItems");
|
||||
let zipItems: typeof allZipItems;
|
||||
|
||||
// Migration code - May 2024. Remove after a bit.
|
||||
//
|
||||
// The older store formats will not have zipItems and instead will have
|
||||
// zipPaths. If we find such a case, read the zipPaths and enqueue all of
|
||||
// their files as zipItems in the result.
|
||||
//
|
||||
// This potentially can be cause us to try reuploading an already uploaded
|
||||
// file, but the dedup logic will kick in at that point so no harm will come
|
||||
// of it.
|
||||
if (allZipItems === undefined) {
|
||||
const allZipPaths = uploadStatusStore.get("filePaths") ?? [];
|
||||
const zipPaths = allZipPaths.filter((f) => existsSync(f));
|
||||
zipItems = [];
|
||||
for (const zip of zipPaths)
|
||||
zipItems = zipItems.concat(await listZipItems(zip));
|
||||
} else {
|
||||
zipItems = allZipItems.filter(([z]) => existsSync(z));
|
||||
}
|
||||
|
||||
if (filePaths.length == 0 && zipItems.length == 0) return undefined;
|
||||
|
||||
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);
|
||||
},
|
||||
collectionName,
|
||||
filePaths,
|
||||
zipItems,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* [Note: Missing values in electron-store]
|
||||
*
|
||||
* Suppose we were to create a store like this:
|
||||
*
|
||||
* const store = new Store({
|
||||
* schema: {
|
||||
* foo: { type: "string" },
|
||||
* bars: { type: "array", items: { type: "string" } },
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* If we fetch `store.get("foo")` or `store.get("bars")`, we get `undefined`.
|
||||
* But if we try to set these back to `undefined`, say `store.set("foo",
|
||||
* someUndefValue)`, we get asked to
|
||||
*
|
||||
* TypeError: Use `delete()` to clear values
|
||||
*
|
||||
* This happens even if we do bulk object updates, e.g. with a JS object that
|
||||
* has undefined keys:
|
||||
*
|
||||
* > TypeError: Setting a value of type `undefined` for key `collectionName` is
|
||||
* > not allowed as it's not supported by JSON
|
||||
*
|
||||
* So what should the TypeScript type for "foo" be?
|
||||
*
|
||||
* If it is were to not include the possibility of `undefined`, then the type
|
||||
* would lie because `store.get("foo")` can indeed be `undefined. But if we were
|
||||
* to include the possibility of `undefined`, then trying to `store.set("foo",
|
||||
* someUndefValue)` will throw.
|
||||
*
|
||||
* The approach we take is to rely on false-y values (empty strings and empty
|
||||
* arrays) to indicate missing values, and then converting those to `undefined`
|
||||
* when reading from the store, and converting `undefined` to the corresponding
|
||||
* false-y value when writing.
|
||||
*/
|
||||
export const setPendingUploads = ({
|
||||
collectionName,
|
||||
filePaths,
|
||||
zipItems,
|
||||
}: PendingUploads) => {
|
||||
uploadStatusStore.set({
|
||||
collectionName: collectionName ?? "",
|
||||
filePaths: filePaths,
|
||||
zipItems: zipItems,
|
||||
});
|
||||
};
|
||||
|
||||
export const markUploadedFiles = (paths: string[]) => {
|
||||
const existing = uploadStatusStore.get("filePaths") ?? [];
|
||||
const updated = existing.filter((p) => !paths.includes(p));
|
||||
uploadStatusStore.set("filePaths", updated);
|
||||
};
|
||||
|
||||
export const markUploadedZipItems = (
|
||||
items: [zipPath: string, entryName: string][],
|
||||
) => {
|
||||
const existing = uploadStatusStore.get("zipItems") ?? [];
|
||||
const updated = existing.filter(
|
||||
(z) => !items.some((e) => z[0] == e[0] && z[1] == e[1]),
|
||||
);
|
||||
uploadStatusStore.set("zipItems", updated);
|
||||
};
|
||||
|
||||
export const clearPendingUploads = () => uploadStatusStore.clear();
|
||||
|
|
|
@ -3,9 +3,10 @@ import { BrowserWindow } from "electron/main";
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { FolderWatch, type CollectionMapping } from "../../types/ipc";
|
||||
import { fsIsDir } from "../fs";
|
||||
import log from "../log";
|
||||
import { watchStore } from "../stores/watch";
|
||||
import { posixPath } from "../utils/electron";
|
||||
import { fsIsDir } from "./fs";
|
||||
|
||||
/**
|
||||
* Create and return a new file system watcher.
|
||||
|
@ -34,8 +35,8 @@ export const createWatcher = (mainWindow: BrowserWindow) => {
|
|||
return watcher;
|
||||
};
|
||||
|
||||
const eventData = (path: string): [string, FolderWatch] => {
|
||||
path = posixPath(path);
|
||||
const eventData = (platformPath: string): [string, FolderWatch] => {
|
||||
const path = posixPath(platformPath);
|
||||
|
||||
const watch = folderWatches().find((watch) =>
|
||||
path.startsWith(watch.folderPath + "/"),
|
||||
|
@ -46,23 +47,15 @@ const eventData = (path: string): [string, FolderWatch] => {
|
|||
return [path, watch];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a file system {@link filePath} that uses the local system specific
|
||||
* path separators into a path that uses POSIX file separators.
|
||||
*/
|
||||
const posixPath = (filePath: string) =>
|
||||
filePath.split(path.sep).join(path.posix.sep);
|
||||
|
||||
export const watchGet = (watcher: FSWatcher) => {
|
||||
const [valid, deleted] = folderWatches().reduce(
|
||||
([valid, deleted], watch) => {
|
||||
(fsIsDir(watch.folderPath) ? valid : deleted).push(watch);
|
||||
return [valid, deleted];
|
||||
},
|
||||
[[], []],
|
||||
);
|
||||
if (deleted.length) {
|
||||
for (const watch of deleted) watchRemove(watcher, watch.folderPath);
|
||||
export const watchGet = async (watcher: FSWatcher): Promise<FolderWatch[]> => {
|
||||
const valid: FolderWatch[] = [];
|
||||
const deletedPaths: string[] = [];
|
||||
for (const watch of folderWatches()) {
|
||||
if (await fsIsDir(watch.folderPath)) valid.push(watch);
|
||||
else deletedPaths.push(watch.folderPath);
|
||||
}
|
||||
if (deletedPaths.length) {
|
||||
await Promise.all(deletedPaths.map((p) => watchRemove(watcher, p)));
|
||||
setFolderWatches(valid);
|
||||
}
|
||||
return valid;
|
||||
|
@ -80,7 +73,7 @@ export const watchAdd = async (
|
|||
) => {
|
||||
const watches = folderWatches();
|
||||
|
||||
if (!fsIsDir(folderPath))
|
||||
if (!(await fsIsDir(folderPath)))
|
||||
throw new Error(
|
||||
`Attempting to add a folder watch for a folder path ${folderPath} that is not an existing directory`,
|
||||
);
|
||||
|
@ -104,7 +97,7 @@ export const watchAdd = async (
|
|||
return watches;
|
||||
};
|
||||
|
||||
export const watchRemove = async (watcher: FSWatcher, folderPath: string) => {
|
||||
export const watchRemove = (watcher: FSWatcher, folderPath: string) => {
|
||||
const watches = folderWatches();
|
||||
const filtered = watches.filter((watch) => watch.folderPath != folderPath);
|
||||
if (watches.length == filtered.length)
|
||||
|
@ -157,3 +150,7 @@ export const watchFindFiles = async (dirPath: string) => {
|
|||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
export const watchReset = (watcher: FSWatcher) => {
|
||||
watcher.unwatch(folderWatches().map((watch) => watch.folderPath));
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Store, { Schema } from "electron-store";
|
||||
|
||||
interface SafeStorageStore {
|
||||
encryptionKey: string;
|
||||
encryptionKey?: string;
|
||||
}
|
||||
|
||||
const safeStorageSchema: Schema<SafeStorageStore> = {
|
||||
|
|
|
@ -1,27 +1,51 @@
|
|||
import Store, { Schema } from "electron-store";
|
||||
|
||||
export interface UploadStatusStore {
|
||||
filePaths: string[];
|
||||
zipPaths: string[];
|
||||
collectionName: string;
|
||||
/**
|
||||
* The collection to which we're uploading, or the root collection.
|
||||
*
|
||||
* Not all pending uploads will have an associated collection.
|
||||
*/
|
||||
collectionName?: string;
|
||||
/**
|
||||
* Paths to regular files that are pending upload.
|
||||
*/
|
||||
filePaths?: string[];
|
||||
/**
|
||||
* Each item is the path to a zip file and the name of an entry within it.
|
||||
*/
|
||||
zipItems?: [zipPath: string, entryName: string][];
|
||||
/**
|
||||
* @deprecated Legacy paths to zip files, now subsumed into zipItems.
|
||||
*/
|
||||
zipPaths?: string[];
|
||||
}
|
||||
|
||||
const uploadStatusSchema: Schema<UploadStatusStore> = {
|
||||
collectionName: {
|
||||
type: "string",
|
||||
},
|
||||
filePaths: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
zipItems: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
zipPaths: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
collectionName: {
|
||||
type: "string",
|
||||
},
|
||||
};
|
||||
|
||||
export const uploadStatusStore = new Store({
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Store, { Schema } from "electron-store";
|
||||
|
||||
interface UserPreferences {
|
||||
hideDockIcon: boolean;
|
||||
hideDockIcon?: boolean;
|
||||
skipAppVersion?: string;
|
||||
muteUpdateNotificationVersion?: string;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { type FolderWatch } from "../../types/ipc";
|
|||
import log from "../log";
|
||||
|
||||
interface WatchStore {
|
||||
mappings: FolderWatchWithLegacyFields[];
|
||||
mappings?: FolderWatchWithLegacyFields[];
|
||||
}
|
||||
|
||||
type FolderWatchWithLegacyFields = FolderWatch & {
|
||||
|
@ -54,8 +54,12 @@ export const watchStore = new Store({
|
|||
*/
|
||||
export const migrateLegacyWatchStoreIfNeeded = () => {
|
||||
let needsUpdate = false;
|
||||
const watches = watchStore.get("mappings")?.map((watch) => {
|
||||
const updatedWatches = [];
|
||||
for (const watch of watchStore.get("mappings") ?? []) {
|
||||
let collectionMapping = watch.collectionMapping;
|
||||
// The required type defines the latest schema, but before migration
|
||||
// this'll be undefined, so tell ESLint to calm down.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!collectionMapping) {
|
||||
collectionMapping = watch.uploadStrategy == 1 ? "parent" : "root";
|
||||
needsUpdate = true;
|
||||
|
@ -64,10 +68,10 @@ export const migrateLegacyWatchStoreIfNeeded = () => {
|
|||
delete watch.rootFolderName;
|
||||
needsUpdate = true;
|
||||
}
|
||||
return { ...watch, collectionMapping };
|
||||
});
|
||||
updatedWatches.push({ ...watch, collectionMapping });
|
||||
}
|
||||
if (needsUpdate) {
|
||||
watchStore.set("mappings", watches);
|
||||
watchStore.set("mappings", updatedWatches);
|
||||
log.info("Migrated legacy watch store data to new schema");
|
||||
}
|
||||
};
|
||||
|
|
|
@ -2,11 +2,14 @@
|
|||
* @file stream data to-from renderer using a custom protocol handler.
|
||||
*/
|
||||
import { net, protocol } from "electron/main";
|
||||
import StreamZip from "node-stream-zip";
|
||||
import { createWriteStream, existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { ReadableStream } from "node:stream/web";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import log from "./log";
|
||||
import { ensure } from "./utils/common";
|
||||
|
||||
/**
|
||||
* Register a protocol handler that we use for streaming large files between the
|
||||
|
@ -34,19 +37,18 @@ export const registerStreamProtocol = () => {
|
|||
protocol.handle("stream", async (request: Request) => {
|
||||
const url = request.url;
|
||||
// The request URL contains the command to run as the host, and the
|
||||
// pathname of the file as the path. For example,
|
||||
//
|
||||
// stream://write/path/to/file
|
||||
// host-pathname-----
|
||||
//
|
||||
const { host, pathname } = new URL(url);
|
||||
// Convert e.g. "%20" to spaces.
|
||||
const path = decodeURIComponent(pathname);
|
||||
// pathname of the file(s) as the search params.
|
||||
const { host, searchParams } = new URL(url);
|
||||
switch (host) {
|
||||
case "read":
|
||||
return handleRead(path);
|
||||
return handleRead(ensure(searchParams.get("path")));
|
||||
case "read-zip":
|
||||
return handleReadZip(
|
||||
ensure(searchParams.get("zipPath")),
|
||||
ensure(searchParams.get("entryName")),
|
||||
);
|
||||
case "write":
|
||||
return handleWrite(path, request);
|
||||
return handleWrite(ensure(searchParams.get("path")), request);
|
||||
default:
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
|
@ -57,10 +59,17 @@ const handleRead = async (path: string) => {
|
|||
try {
|
||||
const res = await net.fetch(pathToFileURL(path).toString());
|
||||
if (res.ok) {
|
||||
// net.fetch defaults to text/plain, which might be fine
|
||||
// in practice, but as an extra precaution indicate that
|
||||
// this is binary data.
|
||||
res.headers.set("Content-Type", "application/octet-stream");
|
||||
// net.fetch already seems to add "Content-Type" and "Last-Modified"
|
||||
// headers, but I couldn't find documentation for this. In any case,
|
||||
// since we already are stat-ting the file for the "Content-Length",
|
||||
// we explicitly add the "X-Last-Modified-Ms" too,
|
||||
//
|
||||
// 1. Guaranteeing its presence,
|
||||
//
|
||||
// 2. Having it be in the exact format we want (no string <-> date
|
||||
// conversions),
|
||||
//
|
||||
// 3. Retaining milliseconds.
|
||||
|
||||
const stat = await fs.stat(path);
|
||||
|
||||
|
@ -75,7 +84,54 @@ const handleRead = async (path: string) => {
|
|||
return res;
|
||||
} catch (e) {
|
||||
log.error(`Failed to read stream at ${path}`, e);
|
||||
return new Response(`Failed to read stream: ${e.message}`, {
|
||||
return new Response(`Failed to read stream: ${String(e)}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReadZip = async (zipPath: string, entryName: string) => {
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
const entry = await zip.entry(entryName);
|
||||
if (!entry) return new Response("", { status: 404 });
|
||||
|
||||
// This returns an "old style" NodeJS.ReadableStream.
|
||||
const stream = await zip.stream(entry);
|
||||
// Convert it into a new style NodeJS.Readable.
|
||||
const nodeReadable = new Readable().wrap(stream);
|
||||
// Then convert it into a Web stream.
|
||||
const webReadableStreamAny = Readable.toWeb(nodeReadable);
|
||||
// However, we get a ReadableStream<any> now. This doesn't go into the
|
||||
// `BodyInit` expected by the Response constructor, which wants a
|
||||
// ReadableStream<Uint8Array>. Force a cast.
|
||||
const webReadableStream =
|
||||
webReadableStreamAny as ReadableStream<Uint8Array>;
|
||||
|
||||
// Close the zip handle when the underlying stream closes.
|
||||
stream.on("end", () => void zip.close());
|
||||
|
||||
return new Response(webReadableStream, {
|
||||
headers: {
|
||||
// We don't know the exact type, but it doesn't really matter,
|
||||
// just set it to a generic binary content-type so that the
|
||||
// browser doesn't tinker with it thinking of it as text.
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": `${entry.size}`,
|
||||
// While it is documented that entry.time is the modification
|
||||
// time, the units are not mentioned. By seeing the source code,
|
||||
// we can verify that it is indeed epoch milliseconds. See
|
||||
// `parseZipTime` in the node-stream-zip source,
|
||||
// https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js
|
||||
"X-Last-Modified-Ms": `${entry.time}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
log.error(
|
||||
`Failed to read entry ${entryName} from zip file at ${zipPath}`,
|
||||
e,
|
||||
);
|
||||
return new Response(`Failed to read stream: ${String(e)}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
@ -83,11 +139,11 @@ const handleRead = async (path: string) => {
|
|||
|
||||
const handleWrite = async (path: string, request: Request) => {
|
||||
try {
|
||||
await writeStream(path, request.body);
|
||||
await writeStream(path, ensure(request.body));
|
||||
return new Response("", { status: 200 });
|
||||
} catch (e) {
|
||||
log.error(`Failed to write stream to ${path}`, e);
|
||||
return new Response(`Failed to write stream: ${e.message}`, {
|
||||
return new Response(`Failed to write stream: ${String(e)}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
@ -99,59 +155,29 @@ const handleWrite = async (path: string, request: Request) => {
|
|||
* The returned promise resolves when the write completes.
|
||||
*
|
||||
* @param filePath The local filesystem path where the file should be written.
|
||||
* @param readableStream A [web
|
||||
* ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
|
||||
*
|
||||
* @param readableStream A web
|
||||
* [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).
|
||||
*/
|
||||
export const writeStream = (filePath: string, readableStream: ReadableStream) =>
|
||||
writeNodeStream(filePath, convertWebReadableStreamToNode(readableStream));
|
||||
writeNodeStream(filePath, Readable.fromWeb(readableStream));
|
||||
|
||||
/**
|
||||
* Convert a Web ReadableStream into a Node.js ReadableStream
|
||||
*
|
||||
* This can be used to, for example, write a ReadableStream obtained via
|
||||
* `net.fetch` into a file using the Node.js `fs` APIs
|
||||
*/
|
||||
const convertWebReadableStreamToNode = (readableStream: ReadableStream) => {
|
||||
const reader = readableStream.getReader();
|
||||
const rs = new Readable();
|
||||
|
||||
rs._read = async () => {
|
||||
try {
|
||||
const result = await reader.read();
|
||||
|
||||
if (!result.done) {
|
||||
rs.push(Buffer.from(result.value));
|
||||
} else {
|
||||
rs.push(null);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
rs.emit("error", e);
|
||||
}
|
||||
};
|
||||
|
||||
return rs;
|
||||
};
|
||||
|
||||
const writeNodeStream = async (
|
||||
filePath: string,
|
||||
fileStream: NodeJS.ReadableStream,
|
||||
) => {
|
||||
const writeNodeStream = async (filePath: string, fileStream: Readable) => {
|
||||
const writeable = createWriteStream(filePath);
|
||||
|
||||
fileStream.on("error", (error) => {
|
||||
writeable.destroy(error); // Close the writable stream with an error
|
||||
fileStream.on("error", (err) => {
|
||||
writeable.destroy(err); // Close the writable stream with an error
|
||||
});
|
||||
|
||||
fileStream.pipe(writeable);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writeable.on("finish", resolve);
|
||||
writeable.on("error", async (e: unknown) => {
|
||||
writeable.on("error", (err) => {
|
||||
if (existsSync(filePath)) {
|
||||
await fs.unlink(filePath);
|
||||
void fs.unlink(filePath);
|
||||
}
|
||||
reject(e);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
import { app } from "electron/main";
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Our very own directory within the system temp directory. Go crazy, but
|
||||
* remember to clean up, especially in exception handlers.
|
||||
*/
|
||||
const enteTempDirPath = async () => {
|
||||
const result = path.join(app.getPath("temp"), "ente");
|
||||
await fs.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Generate a random string suitable for being used as a file name prefix */
|
||||
const randomPrefix = () => {
|
||||
const alphabet =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
let result = "";
|
||||
for (let i = 0; i < 10; i++)
|
||||
result += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the path to a temporary file with the given {@link suffix}.
|
||||
*
|
||||
* The function returns the path to a file in the system temp directory (in an
|
||||
* Ente specific folder therin) with a random prefix and an (optional)
|
||||
* {@link extension}.
|
||||
*
|
||||
* It ensures that there is no existing item with the same name already.
|
||||
*
|
||||
* Use {@link deleteTempFile} to remove this file when you're done.
|
||||
*/
|
||||
export const makeTempFilePath = async (extension?: string) => {
|
||||
const tempDir = await enteTempDirPath();
|
||||
const suffix = extension ? "." + extension : "";
|
||||
let result: string;
|
||||
do {
|
||||
result = path.join(tempDir, randomPrefix() + suffix);
|
||||
} while (existsSync(result));
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a temporary file at the given path if it exists.
|
||||
*
|
||||
* This is the same as a vanilla {@link fs.rm}, except it first checks that the
|
||||
* given path is within the Ente specific directory in the system temp
|
||||
* directory. This acts as an additional safety check.
|
||||
*
|
||||
* @param tempFilePath The path to the temporary file to delete. This path
|
||||
* should've been previously created using {@link makeTempFilePath}.
|
||||
*/
|
||||
export const deleteTempFile = async (tempFilePath: string) => {
|
||||
const tempDir = await enteTempDirPath();
|
||||
if (!tempFilePath.startsWith(tempDir))
|
||||
throw new Error(`Attempting to delete a non-temp file ${tempFilePath}`);
|
||||
await fs.rm(tempFilePath, { force: true });
|
||||
};
|
|
@ -1,10 +1,19 @@
|
|||
/**
|
||||
* @file grab bag of utitity functions.
|
||||
* @file grab bag of utility functions.
|
||||
*
|
||||
* Many of these are verbatim copies of functions from web code since there
|
||||
* isn't currently a common package that both of them share.
|
||||
* These are verbatim copies of functions from web code since there isn't
|
||||
* currently a common package that both of them share.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Throw an exception if the given value is `null` or `undefined`.
|
||||
*/
|
||||
export const ensure = <T>(v: T | null | undefined): T => {
|
||||
if (v === null) throw new Error("Required value was null");
|
||||
if (v === undefined) throw new Error("Required value was not found");
|
||||
return v;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for {@link ms} milliseconds
|
||||
*
|
|
@ -1,14 +1,35 @@
|
|||
import shellescape from "any-shell-escape";
|
||||
import { shell } from "electron"; /* TODO(MR): Why is this not in /main? */
|
||||
import { app } from "electron/main";
|
||||
import { exec } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import log from "./log";
|
||||
import log from "../log";
|
||||
|
||||
/** `true` if the app is running in development mode. */
|
||||
export const isDev = !app.isPackaged;
|
||||
|
||||
/**
|
||||
* Convert a file system {@link platformPath} that uses the local system
|
||||
* specific path separators into a path that uses POSIX file separators.
|
||||
*
|
||||
* For all paths that we persist or pass over the IPC boundary, we always use
|
||||
* POSIX paths, even on Windows.
|
||||
*
|
||||
* Windows recognizes both forward and backslashes. This also works with drive
|
||||
* names. c:\foo\bar and c:/foo/bar are both valid.
|
||||
*
|
||||
* > Almost all paths passed to Windows APIs are normalized. During
|
||||
* > normalization, Windows performs the following steps: ... All forward
|
||||
* > slashes (/) are converted into the standard Windows separator, the back
|
||||
* > slash (\).
|
||||
* >
|
||||
* > https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats
|
||||
*/
|
||||
export const posixPath = (platformPath: string) =>
|
||||
path.sep == path.posix.sep
|
||||
? platformPath
|
||||
: platformPath.split(path.sep).join(path.posix.sep);
|
||||
|
||||
/**
|
||||
* Run a shell command asynchronously.
|
||||
*
|
||||
|
@ -33,49 +54,11 @@ export const execAsync = (command: string | string[]) => {
|
|||
? shellescape(command)
|
||||
: command;
|
||||
const startTime = Date.now();
|
||||
log.debug(() => `Running shell command: ${escapedCommand}`);
|
||||
const result = execAsync_(escapedCommand);
|
||||
log.debug(
|
||||
() =>
|
||||
`Completed in ${Math.round(Date.now() - startTime)} ms (${escapedCommand})`,
|
||||
() => `${escapedCommand} (${Math.round(Date.now() - startTime)} ms)`,
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
const execAsync_ = promisify(exec);
|
||||
|
||||
/**
|
||||
* Open the given {@link dirPath} in the system's folder viewer.
|
||||
*
|
||||
* For example, on macOS this'll open {@link dirPath} in Finder.
|
||||
*/
|
||||
export const openDirectory = async (dirPath: string) => {
|
||||
const res = await shell.openPath(path.normalize(dirPath));
|
||||
// shell.openPath resolves with a string containing the error message
|
||||
// corresponding to the failure if a failure occurred, otherwise "".
|
||||
if (res) throw new Error(`Failed to open directory ${dirPath}: res`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the app's log directory in the system's folder viewer.
|
||||
*
|
||||
* @see {@link openDirectory}
|
||||
*/
|
||||
export const openLogDirectory = () => openDirectory(logDirectoryPath());
|
||||
|
||||
/**
|
||||
* Return the path where the logs for the app are saved.
|
||||
*
|
||||
* [Note: Electron app paths]
|
||||
*
|
||||
* By default, these paths are at the following locations:
|
||||
*
|
||||
* - macOS: `~/Library/Application Support/ente`
|
||||
* - Linux: `~/.config/ente`
|
||||
* - Windows: `%APPDATA%`, e.g. `C:\Users\<username>\AppData\Local\ente`
|
||||
* - Windows: C:\Users\<you>\AppData\Local\<Your App Name>
|
||||
*
|
||||
* https://www.electronjs.org/docs/latest/api/app
|
||||
*
|
||||
*/
|
||||
const logDirectoryPath = () => app.getPath("logs");
|
125
desktop/src/main/utils/temp.ts
Normal file
125
desktop/src/main/utils/temp.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { app } from "electron/main";
|
||||
import StreamZip from "node-stream-zip";
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ZipItem } from "../../types/ipc";
|
||||
import { ensure } from "./common";
|
||||
|
||||
/**
|
||||
* Our very own directory within the system temp directory. Go crazy, but
|
||||
* remember to clean up, especially in exception handlers.
|
||||
*/
|
||||
const enteTempDirPath = async () => {
|
||||
const result = path.join(app.getPath("temp"), "ente");
|
||||
await fs.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Generate a random string suitable for being used as a file name prefix */
|
||||
const randomPrefix = () => {
|
||||
const ch = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const randomChar = () => ensure(ch[Math.floor(Math.random() * ch.length)]);
|
||||
|
||||
return Array(10).fill("").map(randomChar).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the path to a temporary file with the given {@link suffix}.
|
||||
*
|
||||
* The function returns the path to a file in the system temp directory (in an
|
||||
* Ente specific folder therin) with a random prefix and an (optional)
|
||||
* {@link extension}.
|
||||
*
|
||||
* It ensures that there is no existing item with the same name already.
|
||||
*
|
||||
* Use {@link deleteTempFile} to remove this file when you're done.
|
||||
*/
|
||||
export const makeTempFilePath = async (extension?: string) => {
|
||||
const tempDir = await enteTempDirPath();
|
||||
const suffix = extension ? "." + extension : "";
|
||||
let result: string;
|
||||
do {
|
||||
result = path.join(tempDir, randomPrefix() + suffix);
|
||||
} while (existsSync(result));
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a temporary file at the given path if it exists.
|
||||
*
|
||||
* This is the same as a vanilla {@link fs.rm}, except it first checks that the
|
||||
* given path is within the Ente specific directory in the system temp
|
||||
* directory. This acts as an additional safety check.
|
||||
*
|
||||
* @param tempFilePath The path to the temporary file to delete. This path
|
||||
* should've been previously created using {@link makeTempFilePath}.
|
||||
*/
|
||||
export const deleteTempFile = async (tempFilePath: string) => {
|
||||
const tempDir = await enteTempDirPath();
|
||||
if (!tempFilePath.startsWith(tempDir))
|
||||
throw new Error(`Attempting to delete a non-temp file ${tempFilePath}`);
|
||||
await fs.rm(tempFilePath, { force: true });
|
||||
};
|
||||
|
||||
/** The result of {@link makeFileForDataOrPathOrZipItem}. */
|
||||
interface FileForDataOrPathOrZipItem {
|
||||
/**
|
||||
* The path to the file (possibly temporary).
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* `true` if {@link path} points to a temporary file which should be deleted
|
||||
* once we are done processing.
|
||||
*/
|
||||
isFileTemporary: boolean;
|
||||
/**
|
||||
* A function that can be called to actually write the contents of the
|
||||
* source `Uint8Array | string | ZipItem` into the file at {@link path}.
|
||||
*
|
||||
* It will do nothing in the case when the source is already a path. In the
|
||||
* other two cases this function will write the data or zip item into the
|
||||
* file at {@link path}.
|
||||
*/
|
||||
writeToTemporaryFile: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the path to a file, a boolean indicating if this is a temporary path
|
||||
* that needs to be deleted after processing, and a function to write the given
|
||||
* {@link dataOrPathOrZipItem} into that temporary file if needed.
|
||||
*
|
||||
* @param dataOrPathOrZipItem The contents of the file, or the path to an
|
||||
* existing file, or a (path to a zip file, name of an entry within that zip
|
||||
* file) tuple.
|
||||
*/
|
||||
export const makeFileForDataOrPathOrZipItem = async (
|
||||
dataOrPathOrZipItem: Uint8Array | string | ZipItem,
|
||||
): Promise<FileForDataOrPathOrZipItem> => {
|
||||
let path: string;
|
||||
let isFileTemporary: boolean;
|
||||
let writeToTemporaryFile = async () => {
|
||||
/* no-op */
|
||||
};
|
||||
|
||||
if (typeof dataOrPathOrZipItem == "string") {
|
||||
path = dataOrPathOrZipItem;
|
||||
isFileTemporary = false;
|
||||
} else {
|
||||
path = await makeTempFilePath();
|
||||
isFileTemporary = true;
|
||||
if (dataOrPathOrZipItem instanceof Uint8Array) {
|
||||
writeToTemporaryFile = () =>
|
||||
fs.writeFile(path, dataOrPathOrZipItem);
|
||||
} else {
|
||||
writeToTemporaryFile = async () => {
|
||||
const [zipPath, entryName] = dataOrPathOrZipItem;
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
await zip.extract(entryName, path);
|
||||
await zip.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { path, isFileTemporary, writeToTemporaryFile };
|
||||
};
|
|
@ -37,37 +37,37 @@
|
|||
* - [main] desktop/src/main/ipc.ts contains impl
|
||||
*/
|
||||
|
||||
import { contextBridge, ipcRenderer } from "electron/renderer";
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron/renderer";
|
||||
|
||||
// While we can't import other code, we can import types since they're just
|
||||
// needed when compiling and will not be needed or looked around for at runtime.
|
||||
import type {
|
||||
AppUpdate,
|
||||
CollectionMapping,
|
||||
ElectronFile,
|
||||
FolderWatch,
|
||||
PendingUploads,
|
||||
ZipItem,
|
||||
} from "./types/ipc";
|
||||
|
||||
// - General
|
||||
|
||||
const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion");
|
||||
const appVersion = () => ipcRenderer.invoke("appVersion");
|
||||
|
||||
const logToDisk = (message: string): void =>
|
||||
ipcRenderer.send("logToDisk", message);
|
||||
|
||||
const openDirectory = (dirPath: string): Promise<void> =>
|
||||
const openDirectory = (dirPath: string) =>
|
||||
ipcRenderer.invoke("openDirectory", dirPath);
|
||||
|
||||
const openLogDirectory = (): Promise<void> =>
|
||||
ipcRenderer.invoke("openLogDirectory");
|
||||
const openLogDirectory = () => ipcRenderer.invoke("openLogDirectory");
|
||||
|
||||
const selectDirectory = () => ipcRenderer.invoke("selectDirectory");
|
||||
|
||||
const clearStores = () => ipcRenderer.send("clearStores");
|
||||
|
||||
const encryptionKey = (): Promise<string | undefined> =>
|
||||
ipcRenderer.invoke("encryptionKey");
|
||||
const encryptionKey = () => ipcRenderer.invoke("encryptionKey");
|
||||
|
||||
const saveEncryptionKey = (encryptionKey: string): Promise<void> =>
|
||||
const saveEncryptionKey = (encryptionKey: string) =>
|
||||
ipcRenderer.invoke("saveEncryptionKey", encryptionKey);
|
||||
|
||||
const onMainWindowFocus = (cb?: () => void) => {
|
||||
|
@ -99,121 +99,90 @@ const skipAppUpdate = (version: string) => {
|
|||
|
||||
// - FS
|
||||
|
||||
const fsExists = (path: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke("fsExists", path);
|
||||
const fsExists = (path: string) => ipcRenderer.invoke("fsExists", path);
|
||||
|
||||
const fsMkdirIfNeeded = (dirPath: string): Promise<void> =>
|
||||
const fsMkdirIfNeeded = (dirPath: string) =>
|
||||
ipcRenderer.invoke("fsMkdirIfNeeded", dirPath);
|
||||
|
||||
const fsRename = (oldPath: string, newPath: string): Promise<void> =>
|
||||
const fsRename = (oldPath: string, newPath: string) =>
|
||||
ipcRenderer.invoke("fsRename", oldPath, newPath);
|
||||
|
||||
const fsRmdir = (path: string): Promise<void> =>
|
||||
ipcRenderer.invoke("fsRmdir", path);
|
||||
const fsRmdir = (path: string) => ipcRenderer.invoke("fsRmdir", path);
|
||||
|
||||
const fsRm = (path: string): Promise<void> => ipcRenderer.invoke("fsRm", path);
|
||||
const fsRm = (path: string) => ipcRenderer.invoke("fsRm", path);
|
||||
|
||||
const fsReadTextFile = (path: string): Promise<string> =>
|
||||
const fsReadTextFile = (path: string) =>
|
||||
ipcRenderer.invoke("fsReadTextFile", path);
|
||||
|
||||
const fsWriteFile = (path: string, contents: string): Promise<void> =>
|
||||
const fsWriteFile = (path: string, contents: string) =>
|
||||
ipcRenderer.invoke("fsWriteFile", path, contents);
|
||||
|
||||
const fsIsDir = (dirPath: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke("fsIsDir", dirPath);
|
||||
|
||||
const fsSize = (path: string): Promise<number> =>
|
||||
ipcRenderer.invoke("fsSize", path);
|
||||
const fsIsDir = (dirPath: string) => ipcRenderer.invoke("fsIsDir", dirPath);
|
||||
|
||||
// - Conversion
|
||||
|
||||
const convertToJPEG = (imageData: Uint8Array): Promise<Uint8Array> =>
|
||||
const convertToJPEG = (imageData: Uint8Array) =>
|
||||
ipcRenderer.invoke("convertToJPEG", imageData);
|
||||
|
||||
const generateImageThumbnail = (
|
||||
dataOrPath: Uint8Array | string,
|
||||
dataOrPathOrZipItem: Uint8Array | string | ZipItem,
|
||||
maxDimension: number,
|
||||
maxSize: number,
|
||||
): Promise<Uint8Array> =>
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"generateImageThumbnail",
|
||||
dataOrPath,
|
||||
dataOrPathOrZipItem,
|
||||
maxDimension,
|
||||
maxSize,
|
||||
);
|
||||
|
||||
const ffmpegExec = (
|
||||
command: string[],
|
||||
dataOrPath: Uint8Array | string,
|
||||
dataOrPathOrZipItem: Uint8Array | string | ZipItem,
|
||||
outputFileExtension: string,
|
||||
timeoutMS: number,
|
||||
): Promise<Uint8Array> =>
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
"ffmpegExec",
|
||||
command,
|
||||
dataOrPath,
|
||||
dataOrPathOrZipItem,
|
||||
outputFileExtension,
|
||||
timeoutMS,
|
||||
);
|
||||
|
||||
// - ML
|
||||
|
||||
const clipImageEmbedding = (jpegImageData: Uint8Array): Promise<Float32Array> =>
|
||||
const clipImageEmbedding = (jpegImageData: Uint8Array) =>
|
||||
ipcRenderer.invoke("clipImageEmbedding", jpegImageData);
|
||||
|
||||
const clipTextEmbeddingIfAvailable = (
|
||||
text: string,
|
||||
): Promise<Float32Array | undefined> =>
|
||||
const clipTextEmbeddingIfAvailable = (text: string) =>
|
||||
ipcRenderer.invoke("clipTextEmbeddingIfAvailable", text);
|
||||
|
||||
const detectFaces = (input: Float32Array): Promise<Float32Array> =>
|
||||
const detectFaces = (input: Float32Array) =>
|
||||
ipcRenderer.invoke("detectFaces", input);
|
||||
|
||||
const faceEmbedding = (input: Float32Array): Promise<Float32Array> =>
|
||||
const faceEmbedding = (input: Float32Array) =>
|
||||
ipcRenderer.invoke("faceEmbedding", input);
|
||||
|
||||
// - File selection
|
||||
|
||||
// TODO: Deprecated - use dialogs on the renderer process itself
|
||||
|
||||
const selectDirectory = (): Promise<string> =>
|
||||
ipcRenderer.invoke("selectDirectory");
|
||||
|
||||
const showUploadFilesDialog = (): Promise<ElectronFile[]> =>
|
||||
ipcRenderer.invoke("showUploadFilesDialog");
|
||||
|
||||
const showUploadDirsDialog = (): Promise<ElectronFile[]> =>
|
||||
ipcRenderer.invoke("showUploadDirsDialog");
|
||||
|
||||
const showUploadZipDialog = (): Promise<{
|
||||
zipPaths: string[];
|
||||
files: ElectronFile[];
|
||||
}> => ipcRenderer.invoke("showUploadZipDialog");
|
||||
|
||||
// - Watch
|
||||
|
||||
const watchGet = (): Promise<FolderWatch[]> => ipcRenderer.invoke("watchGet");
|
||||
const watchGet = () => ipcRenderer.invoke("watchGet");
|
||||
|
||||
const watchAdd = (
|
||||
folderPath: string,
|
||||
collectionMapping: CollectionMapping,
|
||||
): Promise<FolderWatch[]> =>
|
||||
const watchAdd = (folderPath: string, collectionMapping: CollectionMapping) =>
|
||||
ipcRenderer.invoke("watchAdd", folderPath, collectionMapping);
|
||||
|
||||
const watchRemove = (folderPath: string): Promise<FolderWatch[]> =>
|
||||
const watchRemove = (folderPath: string) =>
|
||||
ipcRenderer.invoke("watchRemove", folderPath);
|
||||
|
||||
const watchUpdateSyncedFiles = (
|
||||
syncedFiles: FolderWatch["syncedFiles"],
|
||||
folderPath: string,
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke("watchUpdateSyncedFiles", syncedFiles, folderPath);
|
||||
) => ipcRenderer.invoke("watchUpdateSyncedFiles", syncedFiles, folderPath);
|
||||
|
||||
const watchUpdateIgnoredFiles = (
|
||||
ignoredFiles: FolderWatch["ignoredFiles"],
|
||||
folderPath: string,
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke("watchUpdateIgnoredFiles", ignoredFiles, folderPath);
|
||||
) => ipcRenderer.invoke("watchUpdateIgnoredFiles", ignoredFiles, folderPath);
|
||||
|
||||
const watchOnAddFile = (f: (path: string, watch: FolderWatch) => void) => {
|
||||
ipcRenderer.removeAllListeners("watchAddFile");
|
||||
|
@ -236,33 +205,56 @@ const watchOnRemoveDir = (f: (path: string, watch: FolderWatch) => void) => {
|
|||
);
|
||||
};
|
||||
|
||||
const watchFindFiles = (folderPath: string): Promise<string[]> =>
|
||||
const watchFindFiles = (folderPath: string) =>
|
||||
ipcRenderer.invoke("watchFindFiles", folderPath);
|
||||
|
||||
const watchReset = async () => {
|
||||
ipcRenderer.removeAllListeners("watchAddFile");
|
||||
ipcRenderer.removeAllListeners("watchRemoveFile");
|
||||
ipcRenderer.removeAllListeners("watchRemoveDir");
|
||||
await ipcRenderer.invoke("watchReset");
|
||||
};
|
||||
|
||||
// - Upload
|
||||
|
||||
const pendingUploads = (): Promise<PendingUploads | undefined> =>
|
||||
ipcRenderer.invoke("pendingUploads");
|
||||
const pathForFile = (file: File) => {
|
||||
const path = webUtils.getPathForFile(file);
|
||||
// The path that we get back from `webUtils.getPathForFile` on Windows uses
|
||||
// "/" as the path separator. Convert them to POSIX separators.
|
||||
//
|
||||
// Note that we do not have access to the path or the os module in the
|
||||
// preload script, thus this hand rolled transformation.
|
||||
|
||||
const setPendingUploadCollection = (collectionName: string): Promise<void> =>
|
||||
ipcRenderer.invoke("setPendingUploadCollection", collectionName);
|
||||
// However that makes TypeScript fidgety since we it cannot find navigator,
|
||||
// as we haven't included "lib": ["dom"] in our tsconfig to avoid making DOM
|
||||
// APIs available to our main Node.js code. We could create a separate
|
||||
// tsconfig just for the preload script, but for now let's go with a cast.
|
||||
//
|
||||
// @ts-expect-error navigator is not defined.
|
||||
const platform = (navigator as { platform: string }).platform;
|
||||
return platform.toLowerCase().includes("win")
|
||||
? path.split("\\").join("/")
|
||||
: path;
|
||||
};
|
||||
|
||||
const setPendingUploadFiles = (
|
||||
type: PendingUploads["type"],
|
||||
filePaths: string[],
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke("setPendingUploadFiles", type, filePaths);
|
||||
const listZipItems = (zipPath: string) =>
|
||||
ipcRenderer.invoke("listZipItems", zipPath);
|
||||
|
||||
// - TODO: AUDIT below this
|
||||
// -
|
||||
const pathOrZipItemSize = (pathOrZipItem: string | ZipItem) =>
|
||||
ipcRenderer.invoke("pathOrZipItemSize", pathOrZipItem);
|
||||
|
||||
const getElectronFilesFromGoogleZip = (
|
||||
filePath: string,
|
||||
): Promise<ElectronFile[]> =>
|
||||
ipcRenderer.invoke("getElectronFilesFromGoogleZip", filePath);
|
||||
const pendingUploads = () => ipcRenderer.invoke("pendingUploads");
|
||||
|
||||
const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
|
||||
ipcRenderer.invoke("getDirFiles", dirPath);
|
||||
const setPendingUploads = (pendingUploads: PendingUploads) =>
|
||||
ipcRenderer.invoke("setPendingUploads", pendingUploads);
|
||||
|
||||
const markUploadedFiles = (paths: PendingUploads["filePaths"]) =>
|
||||
ipcRenderer.invoke("markUploadedFiles", paths);
|
||||
|
||||
const markUploadedZipItems = (items: PendingUploads["zipItems"]) =>
|
||||
ipcRenderer.invoke("markUploadedZipItems", items);
|
||||
|
||||
const clearPendingUploads = () => ipcRenderer.invoke("clearPendingUploads");
|
||||
|
||||
/**
|
||||
* These objects exposed here will become available to the JS code in our
|
||||
|
@ -311,6 +303,7 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
logToDisk,
|
||||
openDirectory,
|
||||
openLogDirectory,
|
||||
selectDirectory,
|
||||
clearStores,
|
||||
encryptionKey,
|
||||
saveEncryptionKey,
|
||||
|
@ -334,7 +327,6 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
readTextFile: fsReadTextFile,
|
||||
writeFile: fsWriteFile,
|
||||
isDir: fsIsDir,
|
||||
size: fsSize,
|
||||
},
|
||||
|
||||
// - Conversion
|
||||
|
@ -350,35 +342,29 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
detectFaces,
|
||||
faceEmbedding,
|
||||
|
||||
// - File selection
|
||||
|
||||
selectDirectory,
|
||||
showUploadFilesDialog,
|
||||
showUploadDirsDialog,
|
||||
showUploadZipDialog,
|
||||
|
||||
// - Watch
|
||||
|
||||
watch: {
|
||||
get: watchGet,
|
||||
add: watchAdd,
|
||||
remove: watchRemove,
|
||||
updateSyncedFiles: watchUpdateSyncedFiles,
|
||||
updateIgnoredFiles: watchUpdateIgnoredFiles,
|
||||
onAddFile: watchOnAddFile,
|
||||
onRemoveFile: watchOnRemoveFile,
|
||||
onRemoveDir: watchOnRemoveDir,
|
||||
findFiles: watchFindFiles,
|
||||
updateSyncedFiles: watchUpdateSyncedFiles,
|
||||
updateIgnoredFiles: watchUpdateIgnoredFiles,
|
||||
reset: watchReset,
|
||||
},
|
||||
|
||||
// - Upload
|
||||
|
||||
pathForFile,
|
||||
listZipItems,
|
||||
pathOrZipItemSize,
|
||||
pendingUploads,
|
||||
setPendingUploadCollection,
|
||||
setPendingUploadFiles,
|
||||
|
||||
// -
|
||||
|
||||
getElectronFilesFromGoogleZip,
|
||||
getDirFiles,
|
||||
setPendingUploads,
|
||||
markUploadedFiles,
|
||||
markUploadedZipItems,
|
||||
clearPendingUploads,
|
||||
});
|
||||
|
|
5
desktop/src/thirdparty/clip-bpe-ts/mod.ts
vendored
5
desktop/src/thirdparty/clip-bpe-ts/mod.ts
vendored
|
@ -1,3 +1,5 @@
|
|||
/* eslint-disable */
|
||||
|
||||
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";
|
||||
|
@ -410,6 +412,7 @@ export default class {
|
|||
newWord.push(first + second);
|
||||
i += 2;
|
||||
} else {
|
||||
// @ts-expect-error "Array indexing can return undefined but not modifying thirdparty code"
|
||||
newWord.push(word[i]);
|
||||
i += 1;
|
||||
}
|
||||
|
@ -434,6 +437,7 @@ export default class {
|
|||
.map((b) => this.byteEncoder[b.charCodeAt(0) as number])
|
||||
.join("");
|
||||
bpeTokens.push(
|
||||
// @ts-expect-error "Array indexing can return undefined but not modifying thirdparty code"
|
||||
...this.bpe(token)
|
||||
.split(" ")
|
||||
.map((bpeToken: string) => this.encoder[bpeToken]),
|
||||
|
@ -458,6 +462,7 @@ export default class {
|
|||
.join("");
|
||||
text = [...text]
|
||||
.map((c) => this.byteDecoder[c])
|
||||
// @ts-expect-error "Array indexing can return undefined but not modifying thirdparty code"
|
||||
.map((v) => String.fromCharCode(v))
|
||||
.join("")
|
||||
.replace(/<\/w>/g, " ");
|
||||
|
|
|
@ -25,10 +25,12 @@ export interface FolderWatchSyncedFile {
|
|||
collectionID: number;
|
||||
}
|
||||
|
||||
export type ZipItem = [zipPath: string, entryName: string];
|
||||
|
||||
export interface PendingUploads {
|
||||
collectionName: string;
|
||||
type: "files" | "zips";
|
||||
files: ElectronFile[];
|
||||
collectionName: string | undefined;
|
||||
filePaths: string[];
|
||||
zipItems: ZipItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -40,25 +42,3 @@ export interface PendingUploads {
|
|||
export const CustomErrorMessage = {
|
||||
NotAvailable: "This feature in not available on the current OS/arch",
|
||||
};
|
||||
|
||||
/**
|
||||
* Deprecated - Use File + webUtils.getPathForFile instead
|
||||
*
|
||||
* Electron used to augment the standard web
|
||||
* [File](https://developer.mozilla.org/en-US/docs/Web/API/File) object with an
|
||||
* additional `path` property. This is now deprecated, and will be removed in a
|
||||
* future release.
|
||||
* https://www.electronjs.org/docs/latest/api/file-object
|
||||
*
|
||||
* The alternative to the `path` property is to use `webUtils.getPathForFile`
|
||||
* https://www.electronjs.org/docs/latest/api/web-utils
|
||||
*/
|
||||
export interface ElectronFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
stream: () => Promise<ReadableStream<Uint8Array>>;
|
||||
blob: () => Promise<Blob>;
|
||||
arrayBuffer: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
|
|
@ -3,71 +3,34 @@
|
|||
into JavaScript that'll then be loaded and run by the main (node) process
|
||||
of our Electron app. */
|
||||
|
||||
/*
|
||||
* Recommended target, lib and other settings for code running in the
|
||||
* version of Node.js bundled with Electron.
|
||||
*
|
||||
* Currently, with Electron 30, this is Node.js 20.11.1.
|
||||
* https://www.electronjs.org/blog/electron-30-0
|
||||
*/
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
|
||||
/* TSConfig docs: https://aka.ms/tsconfig.json */
|
||||
|
||||
"compilerOptions": {
|
||||
/* Recommended target, lib and other settings for code running in the
|
||||
version of Node.js bundled with Electron.
|
||||
|
||||
Currently, with Electron 29, this is Node.js 20.9
|
||||
https://www.electronjs.org/blog/electron-29-0
|
||||
|
||||
Note that we cannot do
|
||||
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
|
||||
because that sets "lib": ["es2023"]. However (and I don't fully
|
||||
understand what's going on here), that breaks our compilation since
|
||||
tsc can then not find type definitions of things like ReadableStream.
|
||||
|
||||
Adding "dom" to "lib" (e.g. `"lib": ["es2023", "dom"]`) fixes the
|
||||
issue, but that doesn't sound correct - the main Electron process
|
||||
isn't running in a browser context.
|
||||
|
||||
It is possible that we're using some of the types incorrectly. For
|
||||
now, we just omit the "lib" definition and rely on the defaults for
|
||||
the "target" we've chosen. This is also what the current
|
||||
electron-forge starter does:
|
||||
|
||||
yarn create electron-app electron-forge-starter -- --template=webpack-typescript
|
||||
|
||||
Enhancement: Can revisit this later.
|
||||
|
||||
Refs:
|
||||
- https://github.com/electron/electron/issues/27092
|
||||
- https://github.com/electron/electron/issues/16146
|
||||
*/
|
||||
|
||||
"target": "es2022",
|
||||
"module": "node16",
|
||||
|
||||
/* Enable various workarounds to play better with CJS libraries */
|
||||
"esModuleInterop": true,
|
||||
/* Speed things up by not type checking `node_modules` */
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Emit the generated JS into `app/` */
|
||||
"outDir": "app",
|
||||
|
||||
/* Temporary overrides to get things to compile with the older config */
|
||||
"strict": false,
|
||||
"noImplicitAny": true
|
||||
|
||||
/* Below is the state we want */
|
||||
/* Enable these one by one */
|
||||
// "strict": true,
|
||||
|
||||
/* Require the `type` modifier when importing types */
|
||||
// "verbatimModuleSyntax": true
|
||||
/* We want this, but it causes "ESM syntax is not allowed in a CommonJS
|
||||
module when 'verbatimModuleSyntax' is enabled" currently */
|
||||
/* "verbatimModuleSyntax": true, */
|
||||
|
||||
"strict": true,
|
||||
/* Stricter than strict */
|
||||
// "noImplicitReturns": true,
|
||||
// "noUnusedParameters": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
/* e.g. makes array indexing returns undefined */
|
||||
// "noUncheckedIndexedAccess": true,
|
||||
// "exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true
|
||||
},
|
||||
/* Transpile all `.ts` files in `src/` */
|
||||
"include": ["src/**/*.ts"]
|
||||
|
|
1064
desktop/yarn.lock
1064
desktop/yarn.lock
File diff suppressed because it is too large
Load diff
|
@ -18,7 +18,7 @@ A guide written by Green, an ente.io lover
|
|||
Migrating from Authy can be tiring, as you cannot export your 2FA codes through
|
||||
the app, meaning that you would have to reconfigure 2FA for all of your accounts
|
||||
for your new 2FA authenticator. However, easier ways exist to export your codes
|
||||
out of Authy. This guide will cover two of the most used methods for mograting
|
||||
out of Authy. This guide will cover two of the most used methods for migrating
|
||||
from Authy to Ente Authenticator.
|
||||
|
||||
> [!CAUTION]
|
||||
|
|
|
@ -25,10 +25,13 @@ configure the endpoint the app should be connecting to.
|
|||
> You can download the CLI from
|
||||
> [here](https://github.com/ente-io/ente/releases?q=tag%3Acli-v0)
|
||||
|
||||
Define a config.yaml and put it either in the same directory as CLI or path
|
||||
defined in env variable `ENTE_CLI_CONFIG_PATH`
|
||||
Define a config.yaml and put it either in the same directory as where you run
|
||||
the CLI from ("current working directory"), or in the path defined in env
|
||||
variable `ENTE_CLI_CONFIG_PATH`:
|
||||
|
||||
```yaml
|
||||
endpoint:
|
||||
api: "http://localhost:8080"
|
||||
```
|
||||
|
||||
(Another [example](https://github.com/ente-io/ente/blob/main/cli/config.yaml.example))
|
||||
|
|
|
@ -108,8 +108,6 @@ PODS:
|
|||
- FlutterMacOS
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- isar_flutter_libs (1.0.0):
|
||||
- Flutter
|
||||
- libwebp (1.3.2):
|
||||
- libwebp/demux (= 1.3.2)
|
||||
- libwebp/mux (= 1.3.2)
|
||||
|
@ -246,7 +244,6 @@ DEPENDENCIES:
|
|||
- image_editor_common (from `.symlinks/plugins/image_editor_common/ios`)
|
||||
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
|
||||
- media_extension (from `.symlinks/plugins/media_extension/ios`)
|
||||
|
@ -341,8 +338,6 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
isar_flutter_libs:
|
||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||
local_auth_darwin:
|
||||
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||
local_auth_ios:
|
||||
|
@ -427,7 +422,6 @@ SPEC CHECKSUMS:
|
|||
image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
|
||||
in_app_purchase_storekit: 0e4b3c2e43ba1e1281f4f46dd71b0593ce529892
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
|
||||
local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
|
|
|
@ -308,7 +308,6 @@
|
|||
"${BUILT_PRODUCTS_DIR}/image_editor_common/image_editor_common.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/in_app_purchase_storekit/in_app_purchase_storekit.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/isar_flutter_libs/isar_flutter_libs.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/local_auth_darwin/local_auth_darwin.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/local_auth_ios/local_auth_ios.framework",
|
||||
|
@ -390,7 +389,6 @@
|
|||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_editor_common.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/in_app_purchase_storekit.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/isar_flutter_libs.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_darwin.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_ios.framework",
|
||||
|
|
|
@ -105,5 +105,14 @@
|
|||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>F5BCEC64._googlecast._tcp</string>
|
||||
</array>
|
||||
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi
|
||||
network.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -1,79 +1,167 @@
|
|||
import "dart:io";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:isar/isar.dart";
|
||||
import "package:path/path.dart";
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import "package:photos/events/embedding_updated_event.dart";
|
||||
import "package:photos/models/embedding.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
class EmbeddingsDB {
|
||||
late final Isar _isar;
|
||||
|
||||
EmbeddingsDB._privateConstructor();
|
||||
|
||||
static final EmbeddingsDB instance = EmbeddingsDB._privateConstructor();
|
||||
|
||||
static const databaseName = "ente.embeddings.db";
|
||||
static const tableName = "embeddings";
|
||||
static const columnFileID = "file_id";
|
||||
static const columnModel = "model";
|
||||
static const columnEmbedding = "embedding";
|
||||
static const columnUpdationTime = "updation_time";
|
||||
|
||||
static Future<SqliteDatabase>? _dbFuture;
|
||||
|
||||
Future<SqliteDatabase> get _database async {
|
||||
_dbFuture ??= _initDatabase();
|
||||
return _dbFuture!;
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_isar = await Isar.open(
|
||||
[EmbeddingSchema],
|
||||
directory: dir.path,
|
||||
);
|
||||
await _clearDeprecatedStore(dir);
|
||||
await _clearDeprecatedStores(dir);
|
||||
}
|
||||
|
||||
Future<SqliteDatabase> _initDatabase() async {
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, databaseName);
|
||||
final migrations = SqliteMigrations()
|
||||
..add(
|
||||
SqliteMigration(
|
||||
1,
|
||||
(tx) async {
|
||||
await tx.execute(
|
||||
'CREATE TABLE $tableName ($columnFileID INTEGER NOT NULL, $columnModel INTEGER NOT NULL, $columnEmbedding BLOB NOT NULL, $columnUpdationTime INTEGER, UNIQUE ($columnFileID, $columnModel))',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
final database = SqliteDatabase(path: path);
|
||||
await migrations.migrate(database);
|
||||
return database;
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
await _isar.writeTxn(() => _isar.clear());
|
||||
final db = await _database;
|
||||
await db.execute('DELETE * FROM $tableName');
|
||||
}
|
||||
|
||||
Future<List<Embedding>> getAll(Model model) async {
|
||||
return _isar.embeddings.filter().modelEqualTo(model).findAll();
|
||||
final db = await _database;
|
||||
final results = await db.getAll('SELECT * FROM $tableName');
|
||||
return _convertToEmbeddings(results);
|
||||
}
|
||||
|
||||
Future<void> put(Embedding embedding) {
|
||||
return _isar.writeTxn(() async {
|
||||
await _isar.embeddings.putByIndex(Embedding.index, embedding);
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
});
|
||||
Future<void> put(Embedding embedding) async {
|
||||
final db = await _database;
|
||||
await db.execute(
|
||||
'INSERT OR REPLACE INTO $tableName ($columnFileID, $columnModel, $columnEmbedding, $columnUpdationTime) VALUES (?, ?, ?, ?)',
|
||||
_getRowFromEmbedding(embedding),
|
||||
);
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
}
|
||||
|
||||
Future<void> putMany(List<Embedding> embeddings) {
|
||||
return _isar.writeTxn(() async {
|
||||
await _isar.embeddings.putAllByIndex(Embedding.index, embeddings);
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
});
|
||||
Future<void> putMany(List<Embedding> embeddings) async {
|
||||
final db = await _database;
|
||||
final inputs = embeddings.map((e) => _getRowFromEmbedding(e)).toList();
|
||||
await db.executeBatch(
|
||||
'INSERT OR REPLACE INTO $tableName ($columnFileID, $columnModel, $columnEmbedding, $columnUpdationTime) values(?, ?, ?, ?)',
|
||||
inputs,
|
||||
);
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
}
|
||||
|
||||
Future<List<Embedding>> getUnsyncedEmbeddings() async {
|
||||
return await _isar.embeddings.filter().updationTimeEqualTo(null).findAll();
|
||||
final db = await _database;
|
||||
final results = await db.getAll(
|
||||
'SELECT * FROM $tableName WHERE $columnUpdationTime IS NULL',
|
||||
);
|
||||
return _convertToEmbeddings(results);
|
||||
}
|
||||
|
||||
Future<void> deleteEmbeddings(List<int> fileIDs) async {
|
||||
await _isar.writeTxn(() async {
|
||||
final embeddings = <Embedding>[];
|
||||
for (final fileID in fileIDs) {
|
||||
embeddings.addAll(
|
||||
await _isar.embeddings.filter().fileIDEqualTo(fileID).findAll(),
|
||||
);
|
||||
}
|
||||
await _isar.embeddings.deleteAll(embeddings.map((e) => e.id).toList());
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
});
|
||||
final db = await _database;
|
||||
await db.execute(
|
||||
'DELETE FROM $tableName WHERE $columnFileID IN (${fileIDs.join(", ")})',
|
||||
);
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
}
|
||||
|
||||
Future<void> deleteAllForModel(Model model) async {
|
||||
await _isar.writeTxn(() async {
|
||||
final embeddings =
|
||||
await _isar.embeddings.filter().modelEqualTo(model).findAll();
|
||||
await _isar.embeddings.deleteAll(embeddings.map((e) => e.id).toList());
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
});
|
||||
final db = await _database;
|
||||
await db.execute(
|
||||
'DELETE FROM $tableName WHERE $columnModel = ?',
|
||||
[modelToInt(model)!],
|
||||
);
|
||||
Bus.instance.fire(EmbeddingUpdatedEvent());
|
||||
}
|
||||
|
||||
Future<void> _clearDeprecatedStore(Directory dir) async {
|
||||
final deprecatedStore = Directory(dir.path + "/object-box-store");
|
||||
if (await deprecatedStore.exists()) {
|
||||
await deprecatedStore.delete(recursive: true);
|
||||
List<Embedding> _convertToEmbeddings(List<Map<String, dynamic>> results) {
|
||||
final List<Embedding> embeddings = [];
|
||||
for (final result in results) {
|
||||
embeddings.add(_getEmbeddingFromRow(result));
|
||||
}
|
||||
return embeddings;
|
||||
}
|
||||
|
||||
Embedding _getEmbeddingFromRow(Map<String, dynamic> row) {
|
||||
final fileID = row[columnFileID];
|
||||
final model = intToModel(row[columnModel])!;
|
||||
final bytes = row[columnEmbedding] as Uint8List;
|
||||
final list = Float32List.view(bytes.buffer);
|
||||
return Embedding(fileID: fileID, model: model, embedding: list);
|
||||
}
|
||||
|
||||
List<Object?> _getRowFromEmbedding(Embedding embedding) {
|
||||
return [
|
||||
embedding.fileID,
|
||||
modelToInt(embedding.model)!,
|
||||
Float32List.fromList(embedding.embedding).buffer.asUint8List(),
|
||||
embedding.updationTime,
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _clearDeprecatedStores(Directory dir) async {
|
||||
final deprecatedObjectBox = Directory(dir.path + "/object-box-store");
|
||||
if (await deprecatedObjectBox.exists()) {
|
||||
await deprecatedObjectBox.delete(recursive: true);
|
||||
}
|
||||
final deprecatedIsar = File(dir.path + "/default.isar");
|
||||
if (await deprecatedIsar.exists()) {
|
||||
await deprecatedIsar.delete();
|
||||
}
|
||||
}
|
||||
|
||||
int? modelToInt(Model model) {
|
||||
switch (model) {
|
||||
case Model.onnxClip:
|
||||
return 1;
|
||||
case Model.ggmlClip:
|
||||
return 2;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Model? intToModel(int model) {
|
||||
switch (model) {
|
||||
case 1:
|
||||
return Model.onnxClip;
|
||||
case 2:
|
||||
return Model.ggmlClip;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -455,6 +455,7 @@ class FilesDB {
|
|||
}
|
||||
|
||||
Future<int> insert(EnteFile file) async {
|
||||
_logger.info("Inserting $file");
|
||||
final db = await instance.database;
|
||||
return db.insert(
|
||||
filesTable,
|
||||
|
|
|
@ -12,10 +12,14 @@ class CastGateway {
|
|||
);
|
||||
return response.data["publicKey"];
|
||||
} catch (e) {
|
||||
if (e is DioError &&
|
||||
e.response != null &&
|
||||
e.response!.statusCode == 404) {
|
||||
return null;
|
||||
if (e is DioError && e.response != null) {
|
||||
if (e.response!.statusCode == 404) {
|
||||
return null;
|
||||
} else if (e.response!.statusCode == 403) {
|
||||
throw CastIPMismatchException();
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
@ -48,3 +52,7 @@ class CastGateway {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CastIPMismatchException implements Exception {
|
||||
CastIPMismatchException();
|
||||
}
|
||||
|
|
26
mobile/lib/generated/intl/messages_en.dart
generated
26
mobile/lib/generated/intl/messages_en.dart
generated
|
@ -357,6 +357,13 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"Authentication failed, please try again"),
|
||||
"authenticationSuccessful":
|
||||
MessageLookupByLibrary.simpleMessage("Authentication successful!"),
|
||||
"autoCastDialogBody": MessageLookupByLibrary.simpleMessage(
|
||||
"You\'ll see available Cast devices here."),
|
||||
"autoCastiOSPermission": MessageLookupByLibrary.simpleMessage(
|
||||
"Make sure Local Network permissions are turned on for the Ente Photos app, in Settings."),
|
||||
"autoPair": MessageLookupByLibrary.simpleMessage("Auto pair"),
|
||||
"autoPairDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Auto pair works only with devices that support Chromecast."),
|
||||
"available": MessageLookupByLibrary.simpleMessage("Available"),
|
||||
"backedUpFolders":
|
||||
MessageLookupByLibrary.simpleMessage("Backed up folders"),
|
||||
|
@ -387,6 +394,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"cannotAddMorePhotosAfterBecomingViewer": m9,
|
||||
"cannotDeleteSharedFiles":
|
||||
MessageLookupByLibrary.simpleMessage("Cannot delete shared files"),
|
||||
"castIPMismatchBody": MessageLookupByLibrary.simpleMessage(
|
||||
"Please make sure you are on the same network as the TV."),
|
||||
"castIPMismatchTitle":
|
||||
MessageLookupByLibrary.simpleMessage("Failed to cast album"),
|
||||
"castInstruction": MessageLookupByLibrary.simpleMessage(
|
||||
"Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV."),
|
||||
"centerPoint": MessageLookupByLibrary.simpleMessage("Center point"),
|
||||
|
@ -460,6 +471,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
MessageLookupByLibrary.simpleMessage("Confirm recovery key"),
|
||||
"confirmYourRecoveryKey":
|
||||
MessageLookupByLibrary.simpleMessage("Confirm your recovery key"),
|
||||
"connectToDevice":
|
||||
MessageLookupByLibrary.simpleMessage("Connect to device"),
|
||||
"contactFamilyAdmin": m12,
|
||||
"contactSupport":
|
||||
MessageLookupByLibrary.simpleMessage("Contact support"),
|
||||
|
@ -721,6 +734,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"filesBackedUpFromDevice": m22,
|
||||
"filesBackedUpInAlbum": m23,
|
||||
"filesDeleted": MessageLookupByLibrary.simpleMessage("Files deleted"),
|
||||
"filesSavedToGallery":
|
||||
MessageLookupByLibrary.simpleMessage("Files saved to gallery"),
|
||||
"flip": MessageLookupByLibrary.simpleMessage("Flip"),
|
||||
"forYourMemories":
|
||||
MessageLookupByLibrary.simpleMessage("for your memories"),
|
||||
|
@ -902,6 +917,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"manageParticipants": MessageLookupByLibrary.simpleMessage("Manage"),
|
||||
"manageSubscription":
|
||||
MessageLookupByLibrary.simpleMessage("Manage subscription"),
|
||||
"manualPairDesc": MessageLookupByLibrary.simpleMessage(
|
||||
"Pair with PIN works with any screen you wish to view your album on."),
|
||||
"map": MessageLookupByLibrary.simpleMessage("Map"),
|
||||
"maps": MessageLookupByLibrary.simpleMessage("Maps"),
|
||||
"mastodon": MessageLookupByLibrary.simpleMessage("Mastodon"),
|
||||
|
@ -936,6 +953,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"no": MessageLookupByLibrary.simpleMessage("No"),
|
||||
"noAlbumsSharedByYouYet":
|
||||
MessageLookupByLibrary.simpleMessage("No albums shared by you yet"),
|
||||
"noDeviceFound":
|
||||
MessageLookupByLibrary.simpleMessage("No device found"),
|
||||
"noDeviceLimit": MessageLookupByLibrary.simpleMessage("None"),
|
||||
"noDeviceThatCanBeDeleted": MessageLookupByLibrary.simpleMessage(
|
||||
"You\'ve no files on this device that can be deleted"),
|
||||
|
@ -982,6 +1001,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"orPickAnExistingOne":
|
||||
MessageLookupByLibrary.simpleMessage("Or pick an existing one"),
|
||||
"pair": MessageLookupByLibrary.simpleMessage("Pair"),
|
||||
"pairWithPin": MessageLookupByLibrary.simpleMessage("Pair with PIN"),
|
||||
"pairingComplete":
|
||||
MessageLookupByLibrary.simpleMessage("Pairing complete"),
|
||||
"passkey": MessageLookupByLibrary.simpleMessage("Passkey"),
|
||||
"passkeyAuthTitle":
|
||||
MessageLookupByLibrary.simpleMessage("Passkey verification"),
|
||||
|
@ -1328,6 +1350,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||
"sparkleSuccess": MessageLookupByLibrary.simpleMessage("✨ Success"),
|
||||
"startBackup": MessageLookupByLibrary.simpleMessage("Start backup"),
|
||||
"status": MessageLookupByLibrary.simpleMessage("Status"),
|
||||
"stopCastingBody": MessageLookupByLibrary.simpleMessage(
|
||||
"Do you want to stop casting?"),
|
||||
"stopCastingTitle":
|
||||
MessageLookupByLibrary.simpleMessage("Stop casting"),
|
||||
"storage": MessageLookupByLibrary.simpleMessage("Storage"),
|
||||
"storageBreakupFamily": MessageLookupByLibrary.simpleMessage("Family"),
|
||||
"storageBreakupYou": MessageLookupByLibrary.simpleMessage("You"),
|
||||
|
|
140
mobile/lib/generated/l10n.dart
generated
140
mobile/lib/generated/l10n.dart
generated
|
@ -5945,6 +5945,16 @@ class S {
|
|||
);
|
||||
}
|
||||
|
||||
/// `Files saved to gallery`
|
||||
String get filesSavedToGallery {
|
||||
return Intl.message(
|
||||
'Files saved to gallery',
|
||||
name: 'filesSavedToGallery',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Failed to save file to gallery`
|
||||
String get fileFailedToSaveToGallery {
|
||||
return Intl.message(
|
||||
|
@ -8378,6 +8388,26 @@ class S {
|
|||
);
|
||||
}
|
||||
|
||||
/// `Auto pair`
|
||||
String get autoPair {
|
||||
return Intl.message(
|
||||
'Auto pair',
|
||||
name: 'autoPair',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Pair with PIN`
|
||||
String get pairWithPin {
|
||||
return Intl.message(
|
||||
'Pair with PIN',
|
||||
name: 'pairWithPin',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Device not found`
|
||||
String get deviceNotFound {
|
||||
return Intl.message(
|
||||
|
@ -8563,6 +8593,116 @@ class S {
|
|||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Auto pair works only with devices that support Chromecast.`
|
||||
String get autoPairDesc {
|
||||
return Intl.message(
|
||||
'Auto pair works only with devices that support Chromecast.',
|
||||
name: 'autoPairDesc',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Pair with PIN works with any screen you wish to view your album on.`
|
||||
String get manualPairDesc {
|
||||
return Intl.message(
|
||||
'Pair with PIN works with any screen you wish to view your album on.',
|
||||
name: 'manualPairDesc',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Connect to device`
|
||||
String get connectToDevice {
|
||||
return Intl.message(
|
||||
'Connect to device',
|
||||
name: 'connectToDevice',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `You'll see available Cast devices here.`
|
||||
String get autoCastDialogBody {
|
||||
return Intl.message(
|
||||
'You\'ll see available Cast devices here.',
|
||||
name: 'autoCastDialogBody',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.`
|
||||
String get autoCastiOSPermission {
|
||||
return Intl.message(
|
||||
'Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.',
|
||||
name: 'autoCastiOSPermission',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `No device found`
|
||||
String get noDeviceFound {
|
||||
return Intl.message(
|
||||
'No device found',
|
||||
name: 'noDeviceFound',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Stop casting`
|
||||
String get stopCastingTitle {
|
||||
return Intl.message(
|
||||
'Stop casting',
|
||||
name: 'stopCastingTitle',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Do you want to stop casting?`
|
||||
String get stopCastingBody {
|
||||
return Intl.message(
|
||||
'Do you want to stop casting?',
|
||||
name: 'stopCastingBody',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Failed to cast album`
|
||||
String get castIPMismatchTitle {
|
||||
return Intl.message(
|
||||
'Failed to cast album',
|
||||
name: 'castIPMismatchTitle',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Please make sure you are on the same network as the TV.`
|
||||
String get castIPMismatchBody {
|
||||
return Intl.message(
|
||||
'Please make sure you are on the same network as the TV.',
|
||||
name: 'castIPMismatchBody',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Pairing complete`
|
||||
String get pairingComplete {
|
||||
return Intl.message(
|
||||
'Pairing complete',
|
||||
name: 'pairingComplete',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLocalizationDelegate extends LocalizationsDelegate<S> {
|
||||
|
|
|
@ -835,6 +835,7 @@
|
|||
"close": "Close",
|
||||
"setAs": "Set as",
|
||||
"fileSavedToGallery": "File saved to gallery",
|
||||
"filesSavedToGallery": "Files saved to gallery",
|
||||
"fileFailedToSaveToGallery": "Failed to save file to gallery",
|
||||
"download": "Download",
|
||||
"pressAndHoldToPlayVideo": "Press and hold to play video",
|
||||
|
@ -1195,6 +1196,8 @@
|
|||
"verifyPasskey": "Verify passkey",
|
||||
"playOnTv": "Play album on TV",
|
||||
"pair": "Pair",
|
||||
"autoPair": "Auto pair",
|
||||
"pairWithPin": "Pair with PIN",
|
||||
"deviceNotFound": "Device not found",
|
||||
"castInstruction": "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.",
|
||||
"deviceCodeHint": "Enter the code",
|
||||
|
@ -1212,5 +1215,16 @@
|
|||
"endpointUpdatedMessage": "Endpoint updated successfully",
|
||||
"customEndpoint": "Connected to {endpoint}",
|
||||
"createCollaborativeLink": "Create collaborative link",
|
||||
"search": "Search"
|
||||
"search": "Search",
|
||||
"autoPairDesc": "Auto pair works only with devices that support Chromecast.",
|
||||
"manualPairDesc": "Pair with PIN works with any screen you wish to view your album on.",
|
||||
"connectToDevice": "Connect to device",
|
||||
"autoCastDialogBody": "You'll see available Cast devices here.",
|
||||
"autoCastiOSPermission": "Make sure Local Network permissions are turned on for the Ente Photos app, in Settings.",
|
||||
"noDeviceFound": "No device found",
|
||||
"stopCastingTitle": "Stop casting",
|
||||
"stopCastingBody": "Do you want to stop casting?",
|
||||
"castIPMismatchTitle": "Failed to cast album",
|
||||
"castIPMismatchBody": "Please make sure you are on the same network as the TV.",
|
||||
"pairingComplete": "Pairing complete"
|
||||
}
|
|
@ -47,7 +47,7 @@
|
|||
"noRecoveryKey": "Nenhuma chave de recuperação?",
|
||||
"sorry": "Desculpe",
|
||||
"noRecoveryKeyNoDecryption": "Devido à natureza do nosso protocolo de criptografia de ponta a ponta, seus dados não podem ser descriptografados sem sua senha ou chave de recuperação",
|
||||
"verifyEmail": "Verificar email",
|
||||
"verifyEmail": "Verificar e-mail",
|
||||
"toResetVerifyEmail": "Para redefinir a sua senha, por favor verifique o seu email primeiro.",
|
||||
"checkInboxAndSpamFolder": "Verifique sua caixa de entrada (e ‘spam’) para concluir a verificação",
|
||||
"tapToEnterCode": "Toque para inserir código",
|
||||
|
@ -156,7 +156,7 @@
|
|||
"addANewEmail": "Adicionar um novo email",
|
||||
"orPickAnExistingOne": "Ou escolha um existente",
|
||||
"collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Os colaboradores podem adicionar fotos e vídeos ao álbum compartilhado.",
|
||||
"enterEmail": "Digite o email",
|
||||
"enterEmail": "Insira o e-mail",
|
||||
"albumOwner": "Proprietário",
|
||||
"@albumOwner": {
|
||||
"description": "Role of the album owner"
|
||||
|
@ -186,7 +186,7 @@
|
|||
"passwordLock": "Bloqueio de senha",
|
||||
"disableDownloadWarningTitle": "Observe",
|
||||
"disableDownloadWarningBody": "Os espectadores ainda podem tirar screenshots ou salvar uma cópia de suas fotos usando ferramentas externas",
|
||||
"allowDownloads": "Permitir transferências",
|
||||
"allowDownloads": "Permitir downloads",
|
||||
"linkDeviceLimit": "Limite do dispositivo",
|
||||
"noDeviceLimit": "Nenhum",
|
||||
"@noDeviceLimit": {
|
||||
|
@ -334,12 +334,12 @@
|
|||
"removeParticipantBody": "{userEmail} será removido deste álbum compartilhado\n\nQuaisquer fotos adicionadas por eles também serão removidas do álbum",
|
||||
"keepPhotos": "Manter fotos",
|
||||
"deletePhotos": "Excluir fotos",
|
||||
"inviteToEnte": "Convidar para o ente",
|
||||
"inviteToEnte": "Convidar para o Ente",
|
||||
"removePublicLink": "Remover link público",
|
||||
"disableLinkMessage": "Isso removerá o link público para acessar \"{albumName}\".",
|
||||
"sharing": "Compartilhando...",
|
||||
"youCannotShareWithYourself": "Você não pode compartilhar consigo mesmo",
|
||||
"archive": "Arquivado",
|
||||
"archive": "Arquivar",
|
||||
"createAlbumActionHint": "Pressione e segure para selecionar fotos e clique em + para criar um álbum",
|
||||
"importing": "Importando....",
|
||||
"failedToLoadAlbums": "Falha ao carregar álbuns",
|
||||
|
@ -353,7 +353,7 @@
|
|||
"singleFileInBothLocalAndRemote": "Este {fileType} está tanto no Ente quanto no seu dispositivo.",
|
||||
"singleFileInRemoteOnly": "Este {fileType} será excluído do Ente.",
|
||||
"singleFileDeleteFromDevice": "Este {fileType} será excluído do seu dispositivo.",
|
||||
"deleteFromEnte": "Excluir do ente",
|
||||
"deleteFromEnte": "Excluir do Ente",
|
||||
"yesDelete": "Sim, excluir",
|
||||
"movedToTrash": "Movido para a lixeira",
|
||||
"deleteFromDevice": "Excluir do dispositivo",
|
||||
|
@ -473,7 +473,7 @@
|
|||
"ignoreUpdate": "Ignorar",
|
||||
"downloading": "Baixando...",
|
||||
"cannotDeleteSharedFiles": "Não é possível excluir arquivos compartilhados",
|
||||
"theDownloadCouldNotBeCompleted": "Não foi possível concluir a transferência",
|
||||
"theDownloadCouldNotBeCompleted": "Não foi possível concluir o download",
|
||||
"retry": "Tentar novamente",
|
||||
"backedUpFolders": "Backup de pastas concluído",
|
||||
"backup": "Backup",
|
||||
|
@ -835,6 +835,7 @@
|
|||
"close": "Fechar",
|
||||
"setAs": "Definir como",
|
||||
"fileSavedToGallery": "Vídeo salvo na galeria",
|
||||
"filesSavedToGallery": "Arquivos salvos na galeria",
|
||||
"fileFailedToSaveToGallery": "Falha ao salvar o arquivo na galeria",
|
||||
"download": "Baixar",
|
||||
"pressAndHoldToPlayVideo": "Pressione e segure para reproduzir o vídeo",
|
||||
|
@ -1195,6 +1196,8 @@
|
|||
"verifyPasskey": "Verificar chave de acesso",
|
||||
"playOnTv": "Reproduzir álbum na TV",
|
||||
"pair": "Parear",
|
||||
"autoPair": "Pareamento automático",
|
||||
"pairWithPin": "Parear com PIN",
|
||||
"deviceNotFound": "Dispositivo não encontrado",
|
||||
"castInstruction": "Visite cast.ente.io no dispositivo que você deseja parear.\n\ndigite o código abaixo para reproduzir o álbum em sua TV.",
|
||||
"deviceCodeHint": "Insira o código",
|
||||
|
@ -1212,5 +1215,16 @@
|
|||
"endpointUpdatedMessage": "Endpoint atualizado com sucesso",
|
||||
"customEndpoint": "Conectado a {endpoint}",
|
||||
"createCollaborativeLink": "Criar link colaborativo",
|
||||
"search": "Pesquisar"
|
||||
"search": "Pesquisar",
|
||||
"autoPairGoogle": "O Pareamento Automático requer a conexão com servidores do Google e só funciona com dispositivos Chromecast. O Google não receberá dados confidenciais, como suas fotos.",
|
||||
"manualPairDesc": "Parear com o PIN funciona para qualquer dispositivo de tela grande onde você deseja reproduzir seu álbum.",
|
||||
"connectToDevice": "Conectar ao dispositivo",
|
||||
"autoCastDialogBody": "Você verá dispositivos disponíveis para transmitir aqui.",
|
||||
"autoCastiOSPermission": "Certifique-se de que as permissões de Rede local estão ativadas para o aplicativo de Fotos Ente, em Configurações.",
|
||||
"noDeviceFound": "Nenhum dispositivo encontrado",
|
||||
"stopCastingTitle": "Parar transmissão",
|
||||
"stopCastingBody": "Você quer parar a transmissão?",
|
||||
"castIPMismatchTitle": "Falha ao transmitir álbum",
|
||||
"castIPMismatchBody": "Certifique-se de estar na mesma rede que a TV.",
|
||||
"pairingComplete": "Pareamento concluído"
|
||||
}
|
|
@ -835,6 +835,7 @@
|
|||
"close": "关闭",
|
||||
"setAs": "设置为",
|
||||
"fileSavedToGallery": "文件已保存到相册",
|
||||
"filesSavedToGallery": "多个文件已保存到相册",
|
||||
"fileFailedToSaveToGallery": "无法将文件保存到相册",
|
||||
"download": "下载",
|
||||
"pressAndHoldToPlayVideo": "按住以播放视频",
|
||||
|
@ -1195,6 +1196,8 @@
|
|||
"verifyPasskey": "验证通行密钥",
|
||||
"playOnTv": "在电视上播放相册",
|
||||
"pair": "配对",
|
||||
"autoPair": "自动配对",
|
||||
"pairWithPin": "用 PIN 配对",
|
||||
"deviceNotFound": "未发现设备",
|
||||
"castInstruction": "在您要配对的设备上访问 cast.ente.io。\n输入下面的代码即可在电视上播放相册。",
|
||||
"deviceCodeHint": "输入代码",
|
||||
|
@ -1212,5 +1215,16 @@
|
|||
"endpointUpdatedMessage": "端点更新成功",
|
||||
"customEndpoint": "已连接至 {endpoint}",
|
||||
"createCollaborativeLink": "创建协作链接",
|
||||
"search": "搜索"
|
||||
"search": "搜索",
|
||||
"autoPairGoogle": "自动配对需要连接到 Google 服务器,且仅适用于支持 Chromecast 的设备。Google 不会接收敏感数据,例如您的照片。",
|
||||
"manualPairDesc": "用 PIN 配对适用于任何大屏幕设备,您可以在这些设备上播放您的相册。",
|
||||
"connectToDevice": "连接到设备",
|
||||
"autoCastDialogBody": "您将在此处看到可用的 Cast 设备。",
|
||||
"autoCastiOSPermission": "请确保已在“设置”中为 Ente Photos 应用打开本地网络权限。",
|
||||
"noDeviceFound": "未发现设备",
|
||||
"stopCastingTitle": "停止投放",
|
||||
"stopCastingBody": "您想停止投放吗?",
|
||||
"castIPMismatchTitle": "投放相册失败",
|
||||
"castIPMismatchBody": "请确保您的设备与电视处于同一网络。",
|
||||
"pairingComplete": "配对完成"
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import "dart:isolate";
|
||||
|
||||
import "package:adaptive_theme/adaptive_theme.dart";
|
||||
import 'package:background_fetch/background_fetch.dart';
|
||||
|
@ -330,10 +331,15 @@ Future<void> _killBGTask([String? taskId]) async {
|
|||
DateTime.now().microsecondsSinceEpoch,
|
||||
);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.remove(kLastBGTaskHeartBeatTime);
|
||||
if (taskId != null) {
|
||||
BackgroundFetch.finish(taskId);
|
||||
}
|
||||
|
||||
///Band aid for background process not getting killed. Should migrate to using
|
||||
///workmanager instead of background_fetch.
|
||||
Isolate.current.kill();
|
||||
}
|
||||
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
|
|
|
@ -1,17 +1,7 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:isar/isar.dart";
|
||||
|
||||
part 'embedding.g.dart';
|
||||
|
||||
@collection
|
||||
class Embedding {
|
||||
static const index = 'unique_file_model_embedding';
|
||||
|
||||
Id id = Isar.autoIncrement;
|
||||
final int fileID;
|
||||
@enumerated
|
||||
@Index(name: index, composite: [CompositeIndex('fileID')], unique: true, replace: true)
|
||||
final Model model;
|
||||
final List<double> embedding;
|
||||
int? updationTime;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -308,7 +308,7 @@ class EnteFile {
|
|||
@override
|
||||
String toString() {
|
||||
return '''File(generatedID: $generatedID, localID: $localID, title: $title,
|
||||
uploadedFileId: $uploadedFileID, modificationTime: $modificationTime,
|
||||
type: $fileType, uploadedFileId: $uploadedFileID, modificationTime: $modificationTime,
|
||||
ownerID: $ownerID, collectionID: $collectionID, updationTime: $updationTime)''';
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import "package:dio/dio.dart";
|
||||
import "package:ente_cast/ente_cast.dart";
|
||||
import "package:ente_cast_normal/ente_cast_normal.dart";
|
||||
import "package:ente_feature_flag/ente_feature_flag.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
|
@ -26,3 +28,9 @@ FlagService get flagService {
|
|||
);
|
||||
return _flagService!;
|
||||
}
|
||||
|
||||
CastService? _castService;
|
||||
CastService get castService {
|
||||
_castService ??= CastServiceImpl();
|
||||
return _castService!;
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ class EmbeddingStore {
|
|||
|
||||
static final EmbeddingStore instance = EmbeddingStore._privateConstructor();
|
||||
|
||||
static const kEmbeddingsSyncTimeKey = "sync_time_embeddings_v2";
|
||||
static const kEmbeddingsSyncTimeKey = "sync_time_embeddings_v3";
|
||||
|
||||
final _logger = Logger("EmbeddingStore");
|
||||
final _dio = NetworkClient.instance.enteDio;
|
||||
|
|
|
@ -72,8 +72,8 @@ class SemanticSearchService {
|
|||
_mlFramework = _currentModel == Model.onnxClip
|
||||
? ONNX(shouldDownloadOverMobileData)
|
||||
: GGML(shouldDownloadOverMobileData);
|
||||
await EmbeddingsDB.instance.init();
|
||||
await EmbeddingStore.instance.init();
|
||||
await EmbeddingsDB.instance.init();
|
||||
await _loadEmbeddings();
|
||||
Bus.instance.on<EmbeddingUpdatedEvent>().listen((event) {
|
||||
_embeddingLoaderDebouncer.run(() async {
|
||||
|
|
|
@ -16,7 +16,7 @@ class UpdateService {
|
|||
static final UpdateService instance = UpdateService._privateConstructor();
|
||||
static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key";
|
||||
static const changeLogVersionKey = "update_change_log_key";
|
||||
static const currentChangeLogVersion = 18;
|
||||
static const currentChangeLogVersion = 19;
|
||||
|
||||
LatestVersionInfo? _latestVersion;
|
||||
final _logger = Logger("UpdateService");
|
||||
|
|
134
mobile/lib/ui/cast/auto.dart
Normal file
134
mobile/lib/ui/cast/auto.dart
Normal file
|
@ -0,0 +1,134 @@
|
|||
import "dart:io";
|
||||
|
||||
import "package:ente_cast/ente_cast.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/common/loading_widget.dart";
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
|
||||
class AutoCastDialog extends StatefulWidget {
|
||||
// async method that takes string as input
|
||||
// and returns void
|
||||
final void Function(String) onConnect;
|
||||
AutoCastDialog(
|
||||
this.onConnect, {
|
||||
Key? key,
|
||||
}) : super(key: key) {}
|
||||
|
||||
@override
|
||||
State<AutoCastDialog> createState() => _AutoCastDialogState();
|
||||
}
|
||||
|
||||
class _AutoCastDialogState extends State<AutoCastDialog> {
|
||||
final bool doesUserExist = true;
|
||||
final Set<Object> _isDeviceTapInProgress = {};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle = getEnteTextTheme(context);
|
||||
final AlertDialog alert = AlertDialog(
|
||||
title: Text(
|
||||
S.of(context).connectToDevice,
|
||||
style: textStyle.largeBold,
|
||||
),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context).autoCastDialogBody,
|
||||
style: textStyle.bodyMuted,
|
||||
),
|
||||
if (Platform.isIOS)
|
||||
Text(
|
||||
S.of(context).autoCastiOSPermission,
|
||||
style: textStyle.bodyMuted,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FutureBuilder<List<(String, Object)>>(
|
||||
future: castService.searchDevices(),
|
||||
builder: (_, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${snapshot.error.toString()}',
|
||||
),
|
||||
);
|
||||
} else if (!snapshot.hasData) {
|
||||
return const EnteLoadingWidget();
|
||||
}
|
||||
|
||||
if (snapshot.data!.isEmpty) {
|
||||
return Center(child: Text(S.of(context).noDeviceFound));
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: snapshot.data!.map((result) {
|
||||
final device = result.$2;
|
||||
final name = result.$1;
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
if (_isDeviceTapInProgress.contains(device)) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isDeviceTapInProgress.add(device);
|
||||
});
|
||||
try {
|
||||
await _connectToYourApp(context, device);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isDeviceTapInProgress.remove(device);
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isDeviceTapInProgress.remove(device);
|
||||
});
|
||||
showGenericErrorDialog(context: context, error: e)
|
||||
.ignore();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
if (_isDeviceTapInProgress.contains(device))
|
||||
const EnteLoadingWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return alert;
|
||||
}
|
||||
|
||||
Future<void> _connectToYourApp(
|
||||
BuildContext context,
|
||||
Object castDevice,
|
||||
) async {
|
||||
await castService.connectDevice(
|
||||
context,
|
||||
castDevice,
|
||||
onMessage: (message) {
|
||||
if (message.containsKey(CastMessageType.pairCode)) {
|
||||
final code = message[CastMessageType.pairCode]!['code'];
|
||||
widget.onConnect(code);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
76
mobile/lib/ui/cast/choose.dart
Normal file
76
mobile/lib/ui/cast/choose.dart
Normal file
|
@ -0,0 +1,76 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/l10n/l10n.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/components/buttons/button_widget.dart";
|
||||
import "package:photos/ui/components/models/button_type.dart";
|
||||
|
||||
class CastChooseDialog extends StatefulWidget {
|
||||
const CastChooseDialog({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CastChooseDialog> createState() => _CastChooseDialogState();
|
||||
}
|
||||
|
||||
class _CastChooseDialogState extends State<CastChooseDialog> {
|
||||
final bool doesUserExist = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle = getEnteTextTheme(context);
|
||||
final AlertDialog alert = AlertDialog(
|
||||
title: Text(
|
||||
context.l10n.playOnTv,
|
||||
style: textStyle.largeBold,
|
||||
),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
S.of(context).autoPairDesc,
|
||||
style: textStyle.bodyMuted,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).autoPair,
|
||||
icon: Icons.cast_outlined,
|
||||
buttonType: ButtonType.neutral,
|
||||
buttonSize: ButtonSize.large,
|
||||
shouldStickToDarkTheme: true,
|
||||
buttonAction: ButtonAction.first,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
isInAlert: true,
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop(ButtonAction.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
Text(
|
||||
S.of(context).manualPairDesc,
|
||||
style: textStyle.bodyMuted,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ButtonWidget(
|
||||
labelText: S.of(context).pairWithPin,
|
||||
buttonType: ButtonType.neutral,
|
||||
// icon for pairing with TV manually
|
||||
icon: Icons.tv_outlined,
|
||||
buttonSize: ButtonSize.large,
|
||||
isInAlert: true,
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop(ButtonAction.second);
|
||||
},
|
||||
shouldStickToDarkTheme: true,
|
||||
buttonAction: ButtonAction.second,
|
||||
shouldSurfaceExecutionStates: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return alert;
|
||||
}
|
||||
}
|
38
mobile/lib/ui/common/popup_item.dart
Normal file
38
mobile/lib/ui/common/popup_item.dart
Normal file
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class EntePopupMenuItem<T> extends PopupMenuItem<T> {
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final Widget? iconWidget;
|
||||
|
||||
EntePopupMenuItem(
|
||||
this.label, {
|
||||
required T value,
|
||||
this.icon,
|
||||
this.iconWidget,
|
||||
Key? key,
|
||||
}) : assert(
|
||||
icon != null || iconWidget != null,
|
||||
'Either icon or iconWidget must be provided.',
|
||||
),
|
||||
assert(
|
||||
!(icon != null && iconWidget != null),
|
||||
'Only one of icon or iconWidget can be provided.',
|
||||
),
|
||||
super(
|
||||
value: value,
|
||||
key: key,
|
||||
child: Row(
|
||||
children: [
|
||||
if (iconWidget != null)
|
||||
iconWidget
|
||||
else if (icon != null)
|
||||
Icon(icon),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(label),
|
||||
],
|
||||
), // Initially empty, will be populated in build
|
||||
);
|
||||
}
|
|
@ -132,14 +132,15 @@ class __BodyState extends State<_Body> {
|
|||
return maxWidth;
|
||||
}
|
||||
|
||||
//Todo: this doesn't give the correct width of the word, make it right
|
||||
double computeWidthOfWord(String text, TextStyle style) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
)..layout();
|
||||
|
||||
return textPainter.size.width;
|
||||
//buffer of 8 added as width is shorter than actual text width
|
||||
return textPainter.size.width + 8;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import "dart:async";
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import 'package:photos/services/update_service.dart';
|
||||
|
@ -9,7 +7,6 @@ import 'package:photos/ui/components/divider_widget.dart';
|
|||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
import 'package:photos/ui/components/title_bar_title_widget.dart';
|
||||
import 'package:photos/ui/notification/update/change_log_entry.dart';
|
||||
import "package:url_launcher/url_launcher_string.dart";
|
||||
|
||||
class ChangeLogPage extends StatefulWidget {
|
||||
const ChangeLogPage({
|
||||
|
@ -81,31 +78,31 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
|||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.trailingIconSecondary,
|
||||
buttonSize: ButtonSize.large,
|
||||
labelText: S.of(context).joinDiscord,
|
||||
icon: Icons.discord_outlined,
|
||||
iconColor: enteColorScheme.primary500,
|
||||
onTap: () async {
|
||||
unawaited(
|
||||
launchUrlString(
|
||||
"https://discord.com/invite/z2YVKkycX3",
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// ButtonWidget(
|
||||
// buttonType: ButtonType.trailingIconSecondary,
|
||||
// buttonSize: ButtonSize.large,
|
||||
// labelText: S.of(context).rateTheApp,
|
||||
// icon: Icons.favorite_rounded,
|
||||
// labelText: S.of(context).joinDiscord,
|
||||
// icon: Icons.discord_outlined,
|
||||
// iconColor: enteColorScheme.primary500,
|
||||
// onTap: () async {
|
||||
// await UpdateService.instance.launchReviewUrl();
|
||||
// unawaited(
|
||||
// launchUrlString(
|
||||
// "https://discord.com/invite/z2YVKkycX3",
|
||||
// mode: LaunchMode.externalApplication,
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
ButtonWidget(
|
||||
buttonType: ButtonType.trailingIconSecondary,
|
||||
buttonSize: ButtonSize.large,
|
||||
labelText: S.of(context).rateTheApp,
|
||||
icon: Icons.favorite_rounded,
|
||||
iconColor: enteColorScheme.primary500,
|
||||
onTap: () async {
|
||||
await UpdateService.instance.launchReviewUrl();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
|
@ -122,18 +119,20 @@ class _ChangeLogPageState extends State<ChangeLogPage> {
|
|||
final List<ChangeLogEntry> items = [];
|
||||
items.addAll([
|
||||
ChangeLogEntry(
|
||||
"Improved Performance for Large Galleries ✨",
|
||||
'We\'ve made significant improvements to how quickly galleries load and'
|
||||
' with less stutter, especially for those with a lot of photos and videos.',
|
||||
"Cast albums to TV ✨",
|
||||
"View a slideshow of your albums on any big screen! Open an album and click on the Cast button to get started.",
|
||||
),
|
||||
ChangeLogEntry(
|
||||
"Enhanced Functionality for Video Backups",
|
||||
'Even if video backups are disabled, you can now manually upload individual videos.',
|
||||
"Organize shared photos",
|
||||
"You can now add shared items to your favorites to any of your personal albums. Ente will create a copy that is fully owned by you and can be organized to your liking.",
|
||||
),
|
||||
ChangeLogEntry(
|
||||
"Bug Fixes",
|
||||
'Many a bugs were squashed in this release.\n'
|
||||
'\nIf you run into any, please write to team@ente.io, or let us know on Discord! 🙏',
|
||||
"Download multiple items",
|
||||
"You can now download multiple items to your gallery at once. Select the items you want to download and click on the download button.",
|
||||
),
|
||||
ChangeLogEntry(
|
||||
"Performance improvements",
|
||||
"This release also brings in major changes that should improve responsiveness. If you discover room for improvement, please let us know!",
|
||||
),
|
||||
]);
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import "dart:async";
|
|||
import 'package:fast_base58/fast_base58.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import "package:logging/logging.dart";
|
||||
import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
|
@ -30,6 +31,8 @@ import 'package:photos/ui/sharing/manage_links_widget.dart';
|
|||
import "package:photos/ui/tools/collage/collage_creator_page.dart";
|
||||
import "package:photos/ui/viewer/location/update_location_data_widget.dart";
|
||||
import 'package:photos/utils/delete_file_util.dart';
|
||||
import "package:photos/utils/dialog_util.dart";
|
||||
import "package:photos/utils/file_download_util.dart";
|
||||
import 'package:photos/utils/magic_util.dart';
|
||||
import 'package:photos/utils/navigation_util.dart';
|
||||
import "package:photos/utils/share_util.dart";
|
||||
|
@ -56,6 +59,7 @@ class FileSelectionActionsWidget extends StatefulWidget {
|
|||
|
||||
class _FileSelectionActionsWidgetState
|
||||
extends State<FileSelectionActionsWidget> {
|
||||
static final _logger = Logger("FileSelectionActionsWidget");
|
||||
late int currentUserID;
|
||||
late FilesSplit split;
|
||||
late CollectionActions collectionActions;
|
||||
|
@ -115,6 +119,8 @@ class _FileSelectionActionsWidgetState
|
|||
!widget.selectedFiles.files.any(
|
||||
(element) => element.fileType == FileType.video,
|
||||
);
|
||||
final showDownloadOption =
|
||||
widget.selectedFiles.files.any((element) => element.localID == null);
|
||||
|
||||
//To animate adding and removing of [SelectedActionButton], add all items
|
||||
//and set [shouldShow] to false for items that should not be shown and true
|
||||
|
@ -367,6 +373,16 @@ class _FileSelectionActionsWidgetState
|
|||
);
|
||||
}
|
||||
|
||||
if (showDownloadOption) {
|
||||
items.add(
|
||||
SelectionActionButton(
|
||||
labelText: S.of(context).download,
|
||||
icon: Icons.cloud_download_outlined,
|
||||
onTap: () => _download(widget.selectedFiles.files.toList()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
SelectionActionButton(
|
||||
labelText: S.of(context).share,
|
||||
|
@ -379,41 +395,36 @@ class _FileSelectionActionsWidgetState
|
|||
),
|
||||
);
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
final scrollController = ScrollController();
|
||||
// h4ck: https://github.com/flutter/flutter/issues/57920#issuecomment-893970066
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).removePadding(removeBottom: true),
|
||||
child: SafeArea(
|
||||
child: Scrollbar(
|
||||
radius: const Radius.circular(1),
|
||||
thickness: 2,
|
||||
controller: scrollController,
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(
|
||||
decelerationRate: ScrollDecelerationRate.fast,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
...items,
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
final scrollController = ScrollController();
|
||||
// h4ck: https://github.com/flutter/flutter/issues/57920#issuecomment-893970066
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).removePadding(removeBottom: true),
|
||||
child: SafeArea(
|
||||
child: Scrollbar(
|
||||
radius: const Radius.circular(1),
|
||||
thickness: 2,
|
||||
controller: scrollController,
|
||||
thumbVisibility: true,
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(
|
||||
decelerationRate: ScrollDecelerationRate.fast,
|
||||
),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
...items,
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// TODO: Return "Select All" here
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _moveFiles() async {
|
||||
|
@ -647,4 +658,29 @@ class _FileSelectionActionsWidgetState
|
|||
widget.selectedFiles.clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _download(List<EnteFile> files) async {
|
||||
final dialog = createProgressDialog(
|
||||
context,
|
||||
S.of(context).downloading,
|
||||
isDismissible: true,
|
||||
);
|
||||
await dialog.show();
|
||||
try {
|
||||
final futures = <Future>[];
|
||||
for (final file in files) {
|
||||
if (file.localID == null) {
|
||||
futures.add(downloadToGallery(file));
|
||||
}
|
||||
}
|
||||
await Future.wait(futures);
|
||||
await dialog.hide();
|
||||
widget.selectedFiles.clearAll();
|
||||
showToast(context, S.of(context).filesSavedToGallery);
|
||||
} catch (e) {
|
||||
_logger.warning("Failed to save files", e);
|
||||
await dialog.hide();
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,30 +4,23 @@ import 'package:flutter/cupertino.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:media_extension/media_extension.dart';
|
||||
import 'package:path/path.dart' as file_path;
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import "package:photos/generated/l10n.dart";
|
||||
import "package:photos/l10n/l10n.dart";
|
||||
import "package:photos/models/file/extensions/file_props.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
import 'package:photos/models/file/trash_file.dart';
|
||||
import 'package:photos/models/ignored_file.dart';
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
import 'package:photos/models/selected_files.dart';
|
||||
import "package:photos/service_locator.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/hidden_service.dart';
|
||||
import 'package:photos/services/ignored_files_service.dart';
|
||||
import 'package:photos/services/local_sync_service.dart';
|
||||
import 'package:photos/ui/collections/collection_action_sheet.dart';
|
||||
import 'package:photos/ui/viewer/file/custom_app_bar.dart';
|
||||
import "package:photos/ui/viewer/file_details/favorite_widget.dart";
|
||||
import "package:photos/ui/viewer/file_details/upload_icon_widget.dart";
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import "package:photos/utils/file_download_util.dart";
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
import "package:photos/utils/magic_util.dart";
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
|
@ -165,7 +158,7 @@ class FileAppBarState extends State<FileAppBar> {
|
|||
Icon(
|
||||
Platform.isAndroid
|
||||
? Icons.download
|
||||
: CupertinoIcons.cloud_download,
|
||||
: Icons.cloud_download_outlined,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
const Padding(
|
||||
|
@ -330,98 +323,16 @@ class FileAppBarState extends State<FileAppBar> {
|
|||
);
|
||||
await dialog.show();
|
||||
try {
|
||||
final FileType type = file.fileType;
|
||||
final bool downloadLivePhotoOnDroid =
|
||||
type == FileType.livePhoto && Platform.isAndroid;
|
||||
AssetEntity? savedAsset;
|
||||
final File? fileToSave = await getFile(file);
|
||||
//Disabling notifications for assets changing to insert the file into
|
||||
//files db before triggering a sync.
|
||||
await PhotoManager.stopChangeNotify();
|
||||
if (type == FileType.image) {
|
||||
savedAsset = await PhotoManager.editor
|
||||
.saveImageWithPath(fileToSave!.path, title: file.title!);
|
||||
} else if (type == FileType.video) {
|
||||
savedAsset = await PhotoManager.editor
|
||||
.saveVideo(fileToSave!, title: file.title!);
|
||||
} else if (type == FileType.livePhoto) {
|
||||
final File? liveVideoFile =
|
||||
await getFileFromServer(file, liveVideo: true);
|
||||
if (liveVideoFile == null) {
|
||||
throw AssertionError("Live video can not be null");
|
||||
}
|
||||
if (downloadLivePhotoOnDroid) {
|
||||
await _saveLivePhotoOnDroid(fileToSave!, liveVideoFile, file);
|
||||
} else {
|
||||
savedAsset = await PhotoManager.editor.darwin.saveLivePhoto(
|
||||
imageFile: fileToSave!,
|
||||
videoFile: liveVideoFile,
|
||||
title: file.title!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedAsset != null) {
|
||||
file.localID = savedAsset.id;
|
||||
await FilesDB.instance.insert(file);
|
||||
Bus.instance.fire(
|
||||
LocalPhotosUpdatedEvent(
|
||||
[file],
|
||||
source: "download",
|
||||
),
|
||||
);
|
||||
} else if (!downloadLivePhotoOnDroid && savedAsset == null) {
|
||||
_logger.severe('Failed to save assert of type $type');
|
||||
}
|
||||
await downloadToGallery(file);
|
||||
showToast(context, S.of(context).fileSavedToGallery);
|
||||
await dialog.hide();
|
||||
} catch (e) {
|
||||
_logger.warning("Failed to save file", e);
|
||||
await dialog.hide();
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
} finally {
|
||||
await PhotoManager.startChangeNotify();
|
||||
LocalSyncService.instance.checkAndSync().ignore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveLivePhotoOnDroid(
|
||||
File image,
|
||||
File video,
|
||||
EnteFile enteFile,
|
||||
) async {
|
||||
debugPrint("Downloading LivePhoto on Droid");
|
||||
AssetEntity? savedAsset = await (PhotoManager.editor
|
||||
.saveImageWithPath(image.path, title: enteFile.title!));
|
||||
if (savedAsset == null) {
|
||||
throw Exception("Failed to save image of live photo");
|
||||
}
|
||||
IgnoredFile ignoreVideoFile = IgnoredFile(
|
||||
savedAsset.id,
|
||||
savedAsset.title ?? '',
|
||||
savedAsset.relativePath ?? 'remoteDownload',
|
||||
"remoteDownload",
|
||||
);
|
||||
await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
|
||||
final videoTitle = file_path.basenameWithoutExtension(enteFile.title!) +
|
||||
file_path.extension(video.path);
|
||||
savedAsset = (await (PhotoManager.editor.saveVideo(
|
||||
video,
|
||||
title: videoTitle,
|
||||
)));
|
||||
if (savedAsset == null) {
|
||||
throw Exception("Failed to save video of live photo");
|
||||
}
|
||||
|
||||
ignoreVideoFile = IgnoredFile(
|
||||
savedAsset.id,
|
||||
savedAsset.title ?? videoTitle,
|
||||
savedAsset.relativePath ?? 'remoteDownload',
|
||||
"remoteDownload",
|
||||
);
|
||||
await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
|
||||
}
|
||||
|
||||
Future<void> _setAs(EnteFile file) async {
|
||||
final dialog = createProgressDialog(context, S.of(context).pleaseWait);
|
||||
await dialog.show();
|
||||
|
|
|
@ -24,6 +24,9 @@ import 'package:photos/services/collections_service.dart';
|
|||
import 'package:photos/services/sync_service.dart';
|
||||
import 'package:photos/services/update_service.dart';
|
||||
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
|
||||
import "package:photos/ui/cast/auto.dart";
|
||||
import "package:photos/ui/cast/choose.dart";
|
||||
import "package:photos/ui/common/popup_item.dart";
|
||||
import 'package:photos/ui/components/action_sheet_widget.dart';
|
||||
import 'package:photos/ui/components/buttons/button_widget.dart';
|
||||
import 'package:photos/ui/components/models/button_type.dart';
|
||||
|
@ -319,263 +322,136 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
),
|
||||
);
|
||||
}
|
||||
final List<PopupMenuItem<AlbumPopupAction>> items = [];
|
||||
if (galleryType.canRename()) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
|
||||
if (widget.collection != null && castService.isSupported) {
|
||||
actions.add(
|
||||
Tooltip(
|
||||
message: "Cast album",
|
||||
child: IconButton(
|
||||
icon: castService.getActiveSessions().isNotEmpty
|
||||
? const Icon(Icons.cast_connected_rounded)
|
||||
: const Icon(Icons.cast_outlined),
|
||||
onPressed: () async {
|
||||
await _castChoiceDialog();
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final List<EntePopupMenuItem<AlbumPopupAction>> items = [];
|
||||
items.addAll([
|
||||
if (galleryType.canRename())
|
||||
EntePopupMenuItem(
|
||||
isQuickLink
|
||||
? S.of(context).convertToAlbum
|
||||
: S.of(context).renameAlbum,
|
||||
value: AlbumPopupAction.rename,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(isQuickLink ? Icons.photo_album_outlined : Icons.edit),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(
|
||||
isQuickLink
|
||||
? S.of(context).convertToAlbum
|
||||
: S.of(context).renameAlbum,
|
||||
),
|
||||
],
|
||||
),
|
||||
icon: isQuickLink ? Icons.photo_album_outlined : Icons.edit,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (galleryType.canSetCover()) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
if (galleryType.canSetCover())
|
||||
EntePopupMenuItem(
|
||||
S.of(context).setCover,
|
||||
value: AlbumPopupAction.setCover,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.image_outlined),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(S.of(context).setCover),
|
||||
],
|
||||
),
|
||||
icon: Icons.image_outlined,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (galleryType.showMap()) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
if (galleryType.showMap())
|
||||
EntePopupMenuItem(
|
||||
S.of(context).map,
|
||||
value: AlbumPopupAction.map,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.map_outlined),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(S.of(context).map),
|
||||
],
|
||||
),
|
||||
icon: Icons.map_outlined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (galleryType.canSort()) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
if (galleryType.canSort())
|
||||
EntePopupMenuItem(
|
||||
S.of(context).sortAlbumsBy,
|
||||
value: AlbumPopupAction.sort,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.sort_outlined),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(
|
||||
S.of(context).sortAlbumsBy,
|
||||
),
|
||||
],
|
||||
),
|
||||
icon: Icons.sort_outlined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (galleryType == GalleryType.uncategorized) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
if (galleryType == GalleryType.uncategorized)
|
||||
EntePopupMenuItem(
|
||||
S.of(context).cleanUncategorized,
|
||||
value: AlbumPopupAction.cleanUncategorized,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.crop_original_outlined),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(S.of(context).cleanUncategorized),
|
||||
],
|
||||
),
|
||||
icon: Icons.crop_original_outlined,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (galleryType.canPin()) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
if (galleryType.canPin())
|
||||
EntePopupMenuItem(
|
||||
widget.collection!.isPinned
|
||||
? S.of(context).unpinAlbum
|
||||
: S.of(context).pinAlbum,
|
||||
value: AlbumPopupAction.pinAlbum,
|
||||
child: Row(
|
||||
children: [
|
||||
widget.collection!.isPinned
|
||||
? const Icon(CupertinoIcons.pin_slash)
|
||||
: Transform.rotate(
|
||||
angle: 45 * math.pi / 180, // rotate by 45 degrees
|
||||
child: const Icon(CupertinoIcons.pin),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(
|
||||
widget.collection!.isPinned
|
||||
? S.of(context).unpinAlbum
|
||||
: S.of(context).pinAlbum,
|
||||
),
|
||||
],
|
||||
),
|
||||
iconWidget: widget.collection!.isPinned
|
||||
? const Icon(CupertinoIcons.pin_slash)
|
||||
: Transform.rotate(
|
||||
angle: 45 * math.pi / 180, // rotate by 45 degrees
|
||||
child: const Icon(CupertinoIcons.pin),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
]);
|
||||
final bool isArchived = widget.collection?.isArchived() ?? false;
|
||||
final bool isHidden = widget.collection?.isHidden() ?? false;
|
||||
// Do not show archive option for favorite collection. If collection is
|
||||
// already archived, allow user to unarchive that collection.
|
||||
if (isArchived || (galleryType.canArchive() && !isHidden)) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: AlbumPopupAction.ownedArchive,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(isArchived ? Icons.unarchive : Icons.archive_outlined),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(
|
||||
isArchived
|
||||
? S.of(context).unarchiveAlbum
|
||||
: S.of(context).archiveAlbum,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isArchived && galleryType.canHide()) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: AlbumPopupAction.ownedHide,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isHidden
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(
|
||||
isHidden ? S.of(context).unhide : S.of(context).hide,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (widget.collection != null && isInternalUser) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: AlbumPopupAction.playOnTv,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.tv_outlined),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(context.l10n.playOnTv),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (galleryType.canDelete()) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: isQuickLink
|
||||
? AlbumPopupAction.removeLink
|
||||
: AlbumPopupAction.delete,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isQuickLink
|
||||
? Icons.remove_circle_outline
|
||||
: Icons.delete_outline,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(
|
||||
isQuickLink
|
||||
? S.of(context).removeLink
|
||||
: S.of(context).deleteAlbum,
|
||||
),
|
||||
],
|
||||
items.addAll(
|
||||
[
|
||||
// Do not show archive option for favorite collection. If collection is
|
||||
// already archived, allow user to unarchive that collection.
|
||||
if (isArchived || (galleryType.canArchive() && !isHidden))
|
||||
EntePopupMenuItem(
|
||||
value: AlbumPopupAction.ownedArchive,
|
||||
isArchived
|
||||
? S.of(context).unarchiveAlbum
|
||||
: S.of(context).archiveAlbum,
|
||||
icon: isArchived ? Icons.unarchive : Icons.archive_outlined,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (galleryType == GalleryType.sharedCollection) {
|
||||
final bool hasShareeArchived = widget.collection!.hasShareeArchived();
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: AlbumPopupAction.sharedArchive,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasShareeArchived ? Icons.unarchive : Icons.archive_outlined,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(
|
||||
hasShareeArchived
|
||||
? S.of(context).unarchiveAlbum
|
||||
: S.of(context).archiveAlbum,
|
||||
),
|
||||
],
|
||||
if (!isArchived && galleryType.canHide())
|
||||
EntePopupMenuItem(
|
||||
value: AlbumPopupAction.ownedHide,
|
||||
isHidden ? S.of(context).unhide : S.of(context).hide,
|
||||
icon: isHidden
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
),
|
||||
);
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: AlbumPopupAction.leave,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.logout),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(S.of(context).leaveAlbum),
|
||||
],
|
||||
if (widget.collection != null && isInternalUser)
|
||||
EntePopupMenuItem(
|
||||
value: AlbumPopupAction.playOnTv,
|
||||
context.l10n.playOnTv,
|
||||
icon: Icons.tv_outlined,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (galleryType == GalleryType.localFolder) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
value: AlbumPopupAction.freeUpSpace,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete_sweep_outlined),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
),
|
||||
Text(S.of(context).freeUpDeviceSpace),
|
||||
],
|
||||
if (galleryType.canDelete())
|
||||
EntePopupMenuItem(
|
||||
isQuickLink ? S.of(context).removeLink : S.of(context).deleteAlbum,
|
||||
value: isQuickLink
|
||||
? AlbumPopupAction.removeLink
|
||||
: AlbumPopupAction.delete,
|
||||
icon: isQuickLink
|
||||
? Icons.remove_circle_outline
|
||||
: Icons.delete_outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (galleryType == GalleryType.sharedCollection)
|
||||
EntePopupMenuItem(
|
||||
widget.collection!.hasShareeArchived()
|
||||
? S.of(context).unarchiveAlbum
|
||||
: S.of(context).archiveAlbum,
|
||||
value: AlbumPopupAction.sharedArchive,
|
||||
icon: widget.collection!.hasShareeArchived()
|
||||
? Icons.unarchive
|
||||
: Icons.archive_outlined,
|
||||
),
|
||||
if (galleryType == GalleryType.sharedCollection)
|
||||
EntePopupMenuItem(
|
||||
S.of(context).leaveAlbum,
|
||||
value: AlbumPopupAction.leave,
|
||||
icon: Icons.logout,
|
||||
),
|
||||
if (galleryType == GalleryType.localFolder)
|
||||
EntePopupMenuItem(
|
||||
S.of(context).freeUpDeviceSpace,
|
||||
value: AlbumPopupAction.freeUpSpace,
|
||||
icon: Icons.delete_sweep_outlined,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (items.isNotEmpty) {
|
||||
actions.add(
|
||||
PopupMenuButton(
|
||||
|
@ -603,7 +479,7 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
} else if (value == AlbumPopupAction.leave) {
|
||||
await _leaveAlbum(context);
|
||||
} else if (value == AlbumPopupAction.playOnTv) {
|
||||
await castAlbum();
|
||||
await _castChoiceDialog();
|
||||
} else if (value == AlbumPopupAction.freeUpSpace) {
|
||||
await _deleteBackedUpFiles(context);
|
||||
} else if (value == AlbumPopupAction.setCover) {
|
||||
|
@ -838,10 +714,56 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> castAlbum() async {
|
||||
Future<void> _castChoiceDialog() async {
|
||||
final gw = CastGateway(NetworkClient.instance.enteDio);
|
||||
if (castService.getActiveSessions().isNotEmpty) {
|
||||
await showChoiceDialog(
|
||||
context,
|
||||
title: S.of(context).stopCastingTitle,
|
||||
firstButtonLabel: S.of(context).yes,
|
||||
secondButtonLabel: S.of(context).no,
|
||||
body: S.of(context).stopCastingBody,
|
||||
firstButtonOnTap: () async {
|
||||
gw.revokeAllTokens().ignore();
|
||||
await castService.closeActiveCasts();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// stop any existing cast session
|
||||
gw.revokeAllTokens().ignore();
|
||||
final result = await showDialog<ButtonAction?>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (BuildContext context) {
|
||||
return const CastChooseDialog();
|
||||
},
|
||||
);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
// wait to allow the dialog to close
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (result == ButtonAction.first) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (BuildContext bContext) {
|
||||
return AutoCastDialog(
|
||||
(device) async {
|
||||
await _castPair(bContext, gw, device);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (result == ButtonAction.second) {
|
||||
await _pairWithPin(gw, '');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pairWithPin(CastGateway gw, String code) async {
|
||||
await showTextInputDialog(
|
||||
context,
|
||||
title: context.l10n.playOnTv,
|
||||
|
@ -849,28 +771,59 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
submitButtonLabel: S.of(context).pair,
|
||||
textInputType: TextInputType.streetAddress,
|
||||
hintText: context.l10n.deviceCodeHint,
|
||||
showOnlyLoadingState: true,
|
||||
alwaysShowSuccessState: false,
|
||||
initialValue: code,
|
||||
onSubmit: (String text) async {
|
||||
try {
|
||||
final code = text.trim();
|
||||
final String? publicKey = await gw.getPublicKey(code);
|
||||
if (publicKey == null) {
|
||||
showToast(context, S.of(context).deviceNotFound);
|
||||
return;
|
||||
}
|
||||
final String castToken = const Uuid().v4().toString();
|
||||
final castPayload = CollectionsService.instance
|
||||
.getCastData(castToken, widget.collection!, publicKey);
|
||||
await gw.publishCastPayload(
|
||||
code,
|
||||
castPayload,
|
||||
widget.collection!.id,
|
||||
castToken,
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.severe("Failed to cast album", e, s);
|
||||
await showGenericErrorDialog(context: context, error: e);
|
||||
final bool paired = await _castPair(context, gw, text);
|
||||
if (!paired) {
|
||||
Future.delayed(Duration.zero, () => _pairWithPin(gw, code));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String lastCode = '';
|
||||
Future<bool> _castPair(
|
||||
BuildContext bContext, CastGateway gw, String code) async {
|
||||
try {
|
||||
if (lastCode == code) {
|
||||
return false;
|
||||
}
|
||||
lastCode = code;
|
||||
_logger.info("Casting album to device with code $code");
|
||||
final String? publicKey = await gw.getPublicKey(code);
|
||||
if (publicKey == null) {
|
||||
showToast(context, S.of(context).deviceNotFound);
|
||||
|
||||
return false;
|
||||
}
|
||||
final String castToken = const Uuid().v4().toString();
|
||||
final castPayload = CollectionsService.instance
|
||||
.getCastData(castToken, widget.collection!, publicKey);
|
||||
_logger.info("Casting album with token $castToken");
|
||||
await gw.publishCastPayload(
|
||||
code,
|
||||
castPayload,
|
||||
widget.collection!.id,
|
||||
castToken,
|
||||
);
|
||||
_logger.info("Casted album with token $castToken");
|
||||
// showToast(bContext, S.of(context).pairingComplete);
|
||||
return true;
|
||||
} catch (e, s) {
|
||||
lastCode = '';
|
||||
_logger.severe("Failed to cast album", e, s);
|
||||
if (e is CastIPMismatchException) {
|
||||
await showErrorDialog(
|
||||
context,
|
||||
S.of(context).castIPMismatchTitle,
|
||||
S.of(context).castIPMismatchBody,
|
||||
);
|
||||
} else {
|
||||
await showGenericErrorDialog(context: bContext, error: e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,14 +4,23 @@ import "package:computer/computer.dart";
|
|||
import 'package:dio/dio.dart';
|
||||
import "package:flutter/foundation.dart";
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as file_path;
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import "package:photos/core/event_bus.dart";
|
||||
import 'package:photos/core/network/network.dart';
|
||||
import "package:photos/db/files_db.dart";
|
||||
import "package:photos/events/local_photos_updated_event.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/ignored_file.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import "package:photos/services/ignored_files_service.dart";
|
||||
import "package:photos/services/local_sync_service.dart";
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import "package:photos/utils/data_util.dart";
|
||||
import "package:photos/utils/fake_progress.dart";
|
||||
import "package:photos/utils/file_util.dart";
|
||||
|
||||
final _logger = Logger("file_download_util");
|
||||
|
||||
|
@ -115,6 +124,97 @@ Future<Uint8List> getFileKeyUsingBgWorker(EnteFile file) async {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> downloadToGallery(EnteFile file) async {
|
||||
try {
|
||||
final FileType type = file.fileType;
|
||||
final bool downloadLivePhotoOnDroid =
|
||||
type == FileType.livePhoto && Platform.isAndroid;
|
||||
AssetEntity? savedAsset;
|
||||
final File? fileToSave = await getFile(file);
|
||||
//Disabling notifications for assets changing to insert the file into
|
||||
//files db before triggering a sync.
|
||||
await PhotoManager.stopChangeNotify();
|
||||
if (type == FileType.image) {
|
||||
savedAsset = await PhotoManager.editor
|
||||
.saveImageWithPath(fileToSave!.path, title: file.title!);
|
||||
} else if (type == FileType.video) {
|
||||
savedAsset =
|
||||
await PhotoManager.editor.saveVideo(fileToSave!, title: file.title!);
|
||||
} else if (type == FileType.livePhoto) {
|
||||
final File? liveVideoFile =
|
||||
await getFileFromServer(file, liveVideo: true);
|
||||
if (liveVideoFile == null) {
|
||||
throw AssertionError("Live video can not be null");
|
||||
}
|
||||
if (downloadLivePhotoOnDroid) {
|
||||
await _saveLivePhotoOnDroid(fileToSave!, liveVideoFile, file);
|
||||
} else {
|
||||
savedAsset = await PhotoManager.editor.darwin.saveLivePhoto(
|
||||
imageFile: fileToSave!,
|
||||
videoFile: liveVideoFile,
|
||||
title: file.title!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedAsset != null) {
|
||||
file.localID = savedAsset.id;
|
||||
await FilesDB.instance.insert(file);
|
||||
Bus.instance.fire(
|
||||
LocalPhotosUpdatedEvent(
|
||||
[file],
|
||||
source: "download",
|
||||
),
|
||||
);
|
||||
} else if (!downloadLivePhotoOnDroid && savedAsset == null) {
|
||||
_logger.severe('Failed to save assert of type $type');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe("Failed to save file", e);
|
||||
rethrow;
|
||||
} finally {
|
||||
await PhotoManager.startChangeNotify();
|
||||
LocalSyncService.instance.checkAndSync().ignore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveLivePhotoOnDroid(
|
||||
File image,
|
||||
File video,
|
||||
EnteFile enteFile,
|
||||
) async {
|
||||
debugPrint("Downloading LivePhoto on Droid");
|
||||
AssetEntity? savedAsset = await (PhotoManager.editor
|
||||
.saveImageWithPath(image.path, title: enteFile.title!));
|
||||
if (savedAsset == null) {
|
||||
throw Exception("Failed to save image of live photo");
|
||||
}
|
||||
IgnoredFile ignoreVideoFile = IgnoredFile(
|
||||
savedAsset.id,
|
||||
savedAsset.title ?? '',
|
||||
savedAsset.relativePath ?? 'remoteDownload',
|
||||
"remoteDownload",
|
||||
);
|
||||
await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
|
||||
final videoTitle = file_path.basenameWithoutExtension(enteFile.title!) +
|
||||
file_path.extension(video.path);
|
||||
savedAsset = (await (PhotoManager.editor.saveVideo(
|
||||
video,
|
||||
title: videoTitle,
|
||||
)));
|
||||
if (savedAsset == null) {
|
||||
throw Exception("Failed to save video of live photo");
|
||||
}
|
||||
|
||||
ignoreVideoFile = IgnoredFile(
|
||||
savedAsset.id,
|
||||
savedAsset.title ?? videoTitle,
|
||||
savedAsset.relativePath ?? 'remoteDownload',
|
||||
"remoteDownload",
|
||||
);
|
||||
await IgnoredFilesService.instance.cacheAndInsert([ignoreVideoFile]);
|
||||
}
|
||||
|
||||
Uint8List _decryptFileKey(Map<String, dynamic> args) {
|
||||
final encryptedKey = CryptoUtil.base642bin(args["encryptedKey"]);
|
||||
final nonce = CryptoUtil.base642bin(args["keyDecryptionNonce"]);
|
||||
|
|
|
@ -357,10 +357,16 @@ class FileUploader {
|
|||
final List<ConnectivityResult> connections =
|
||||
await (Connectivity().checkConnectivity());
|
||||
bool canUploadUnderCurrentNetworkConditions = true;
|
||||
if (connections.any((element) => element == ConnectivityResult.mobile)) {
|
||||
canUploadUnderCurrentNetworkConditions =
|
||||
Configuration.instance.shouldBackupOverMobileData();
|
||||
if (!Configuration.instance.shouldBackupOverMobileData()) {
|
||||
if (connections.any((element) => element == ConnectivityResult.mobile)) {
|
||||
canUploadUnderCurrentNetworkConditions = false;
|
||||
} else {
|
||||
_logger.info(
|
||||
"mobileBackupDisabled, backing up with connections: ${connections.map((e) => e.name).toString()}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canUploadUnderCurrentNetworkConditions) {
|
||||
throw WiFiUnavailableError();
|
||||
}
|
||||
|
@ -370,7 +376,13 @@ class FileUploader {
|
|||
if (Platform.isAndroid) {
|
||||
final bool hasPermission = await Permission.accessMediaLocation.isGranted;
|
||||
if (!hasPermission) {
|
||||
throw NoMediaLocationAccessError();
|
||||
final permissionStatus = await Permission.accessMediaLocation.request();
|
||||
if (!permissionStatus.isGranted) {
|
||||
_logger.severe(
|
||||
"Media location access denied with permission status: ${permissionStatus.name}",
|
||||
);
|
||||
throw NoMediaLocationAccessError();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
10
mobile/plugins/ente_cast/.metadata
Normal file
10
mobile/plugins/ente_cast/.metadata
Normal file
|
@ -0,0 +1,10 @@
|
|||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
|
||||
channel: stable
|
||||
|
||||
project_type: plugin
|
1
mobile/plugins/ente_cast/analysis_options.yaml
Normal file
1
mobile/plugins/ente_cast/analysis_options.yaml
Normal file
|
@ -0,0 +1 @@
|
|||
include: ../../analysis_options.yaml
|
2
mobile/plugins/ente_cast/lib/ente_cast.dart
Normal file
2
mobile/plugins/ente_cast/lib/ente_cast.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
export 'src/model.dart';
|
||||
export 'src/service.dart';
|
5
mobile/plugins/ente_cast/lib/src/model.dart
Normal file
5
mobile/plugins/ente_cast/lib/src/model.dart
Normal file
|
@ -0,0 +1,5 @@
|
|||
// create enum for type of message for cast
|
||||
enum CastMessageType {
|
||||
pairCode,
|
||||
alreadyCasting,
|
||||
}
|
18
mobile/plugins/ente_cast/lib/src/service.dart
Normal file
18
mobile/plugins/ente_cast/lib/src/service.dart
Normal file
|
@ -0,0 +1,18 @@
|
|||
import "package:ente_cast/src/model.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
|
||||
abstract class CastService {
|
||||
bool get isSupported;
|
||||
Future<List<(String, Object)>> searchDevices();
|
||||
Future<void> connectDevice(
|
||||
BuildContext context,
|
||||
Object device, {
|
||||
int? collectionID,
|
||||
// callback that take a map of string, dynamic
|
||||
void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
|
||||
});
|
||||
// returns a map of sessionID to deviceNames
|
||||
Map<String, String> getActiveSessions();
|
||||
|
||||
Future<void> closeActiveCasts();
|
||||
}
|
19
mobile/plugins/ente_cast/pubspec.yaml
Normal file
19
mobile/plugins/ente_cast/pubspec.yaml
Normal file
|
@ -0,0 +1,19 @@
|
|||
name: ente_cast
|
||||
version: 0.0.1
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
collection:
|
||||
dio: ^4.0.6
|
||||
flutter:
|
||||
sdk: flutter
|
||||
shared_preferences: ^2.0.5
|
||||
stack_trace:
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints:
|
||||
|
||||
flutter:
|
10
mobile/plugins/ente_cast_none/.metadata
Normal file
10
mobile/plugins/ente_cast_none/.metadata
Normal file
|
@ -0,0 +1,10 @@
|
|||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
|
||||
channel: stable
|
||||
|
||||
project_type: plugin
|
1
mobile/plugins/ente_cast_none/analysis_options.yaml
Normal file
1
mobile/plugins/ente_cast_none/analysis_options.yaml
Normal file
|
@ -0,0 +1 @@
|
|||
include: ../../analysis_options.yaml
|
1
mobile/plugins/ente_cast_none/lib/ente_cast_none.dart
Normal file
1
mobile/plugins/ente_cast_none/lib/ente_cast_none.dart
Normal file
|
@ -0,0 +1 @@
|
|||
export 'src/service.dart';
|
35
mobile/plugins/ente_cast_none/lib/src/service.dart
Normal file
35
mobile/plugins/ente_cast_none/lib/src/service.dart
Normal file
|
@ -0,0 +1,35 @@
|
|||
import "package:ente_cast/ente_cast.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
|
||||
class CastServiceImpl extends CastService {
|
||||
@override
|
||||
Future<void> connectDevice(
|
||||
BuildContext context,
|
||||
Object device, {
|
||||
int? collectionID,
|
||||
void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isSupported => false;
|
||||
|
||||
@override
|
||||
Future<List<(String, Object)>> searchDevices() {
|
||||
// TODO: implement searchDevices
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> closeActiveCasts() {
|
||||
// TODO: implement closeActiveCasts
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, String> getActiveSessions() {
|
||||
// TODO: implement getActiveSessions
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
18
mobile/plugins/ente_cast_none/pubspec.yaml
Normal file
18
mobile/plugins/ente_cast_none/pubspec.yaml
Normal file
|
@ -0,0 +1,18 @@
|
|||
name: ente_cast_none
|
||||
version: 0.0.1
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
ente_cast:
|
||||
path: ../ente_cast
|
||||
flutter:
|
||||
sdk: flutter
|
||||
stack_trace:
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints:
|
||||
|
||||
flutter:
|
10
mobile/plugins/ente_cast_normal/.metadata
Normal file
10
mobile/plugins/ente_cast_normal/.metadata
Normal file
|
@ -0,0 +1,10 @@
|
|||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 0b8abb4724aa590dd0f429683339b1e045a1594d
|
||||
channel: stable
|
||||
|
||||
project_type: plugin
|
1
mobile/plugins/ente_cast_normal/analysis_options.yaml
Normal file
1
mobile/plugins/ente_cast_normal/analysis_options.yaml
Normal file
|
@ -0,0 +1 @@
|
|||
include: ../../analysis_options.yaml
|
|
@ -0,0 +1 @@
|
|||
export 'src/service.dart';
|
100
mobile/plugins/ente_cast_normal/lib/src/service.dart
Normal file
100
mobile/plugins/ente_cast_normal/lib/src/service.dart
Normal file
|
@ -0,0 +1,100 @@
|
|||
import "dart:developer" as dev;
|
||||
|
||||
import "package:cast/cast.dart";
|
||||
import "package:ente_cast/ente_cast.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class CastServiceImpl extends CastService {
|
||||
final String _appId = 'F5BCEC64';
|
||||
final String _pairRequestNamespace = 'urn:x-cast:pair-request';
|
||||
final Map<int, String> collectionIDToSessions = {};
|
||||
|
||||
@override
|
||||
Future<void> connectDevice(
|
||||
BuildContext context,
|
||||
Object device, {
|
||||
int? collectionID,
|
||||
void Function(Map<CastMessageType, Map<String, dynamic>>)? onMessage,
|
||||
}) async {
|
||||
final CastDevice castDevice = device as CastDevice;
|
||||
final session = await CastSessionManager().startSession(castDevice);
|
||||
session.messageStream.listen((message) {
|
||||
if (message['type'] == "RECEIVER_STATUS") {
|
||||
dev.log(
|
||||
"got RECEIVER_STATUS, Send request to pair",
|
||||
name: "CastServiceImpl",
|
||||
);
|
||||
session.sendMessage(_pairRequestNamespace, {});
|
||||
} else {
|
||||
if (onMessage != null && message.containsKey("code")) {
|
||||
onMessage(
|
||||
{
|
||||
CastMessageType.pairCode: message,
|
||||
},
|
||||
);
|
||||
}
|
||||
print('receive message: $message');
|
||||
}
|
||||
});
|
||||
|
||||
session.stateStream.listen((state) {
|
||||
if (state == CastSessionState.connected) {
|
||||
debugPrint("Send request to pair");
|
||||
session.sendMessage(_pairRequestNamespace, {});
|
||||
} else if (state == CastSessionState.closed) {
|
||||
dev.log('Session closed', name: 'CastServiceImpl');
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint("Send request to launch");
|
||||
session.sendMessage(CastSession.kNamespaceReceiver, {
|
||||
'type': 'LAUNCH',
|
||||
'appId': _appId, // set the appId of your app here
|
||||
});
|
||||
// session.sendMessage('urn:x-cast:pair-request', {});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<(String, Object)>> searchDevices() {
|
||||
return CastDiscoveryService().search().then((devices) {
|
||||
return devices.map((device) => (device.name, device)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<void> closeActiveCasts() {
|
||||
final sessions = CastSessionManager().sessions;
|
||||
for (final session in sessions) {
|
||||
debugPrint("send close message for ${session.sessionId}");
|
||||
Future(() {
|
||||
session.sendMessage(CastSession.kNamespaceConnection, {
|
||||
'type': 'CLOSE',
|
||||
});
|
||||
}).timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
debugPrint('sendMessage timed out after 5 seconds');
|
||||
},
|
||||
);
|
||||
debugPrint("close session ${session.sessionId}");
|
||||
session.close();
|
||||
}
|
||||
CastSessionManager().sessions.clear();
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, String> getActiveSessions() {
|
||||
final sessions = CastSessionManager().sessions;
|
||||
final Map<String, String> result = {};
|
||||
for (final session in sessions) {
|
||||
if (session.state == CastSessionState.connected) {
|
||||
result[session.sessionId] = session.state.toString();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
333
mobile/plugins/ente_cast_normal/pubspec.lock
Normal file
333
mobile/plugins/ente_cast_normal/pubspec.lock
Normal file
|
@ -0,0 +1,333 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
cast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: multicast_version
|
||||
resolved-ref: "1f39cd4d6efa9363e77b2439f0317bae0c92dda1"
|
||||
url: "https://github.com/guyluz11/flutter_cast.git"
|
||||
source: git
|
||||
version: "2.0.9"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
dio:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio
|
||||
sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
ente_cast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../ente_cast"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.0"
|
||||
multicast_dns:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: multicast_dns
|
||||
sha256: "316cc47a958d4bd3c67bd238fe8b44fdfb6133bad89cb191c0c3bd3edb14e296"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.2+6"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: protobuf
|
||||
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.5"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.99"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
stack_trace:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.4.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
sdks:
|
||||
dart: ">=3.3.0 <4.0.0"
|
||||
flutter: ">=3.19.0"
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue