fix: logics and ui (button, error code)

This commit is contained in:
Prateek Sunal 2024-05-04 14:34:05 +05:30
commit d1a15b129a
200 changed files with 6006 additions and 5567 deletions

View file

@ -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
View 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

View file

@ -54,3 +54,4 @@ jobs:
packageName: io.ente.photos
releaseFiles: mobile/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab
track: internal
changesNotSentForReview: true

View file

@ -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 {

View file

@ -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(

View file

@ -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;

View file

@ -92,6 +92,7 @@ Future<int?> _processBitwardenExportFile(
var account = item['login']['username'];
code = Code.fromAccountAndSecret(
Type.totp,
account,
issuer,
totp,

View file

@ -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;
}
}
}

View file

@ -11,7 +11,7 @@ display_name: Auth
requires:
- libsqlite3x
- webkit2gtk-4.0
- webkit2gtk4.0
- libsodium
- libsecret
- libappindicator

View file

@ -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

View file

@ -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

View file

@ -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,
},
],
},
};

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -29,4 +29,5 @@ mac:
arch: [universal]
category: public.app-category.photography
hardenedRuntime: true
notarize: true
afterSign: electron-builder-notarize

View file

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

View file

@ -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

View file

@ -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,
};
};

View file

@ -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);

View file

@ -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;
}

View file

@ -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));
};

View file

@ -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)}`);

View file

@ -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([
{

View file

@ -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;

View file

@ -38,7 +38,7 @@ class AutoLauncher {
}
}
async wasAutoLaunched() {
wasAutoLaunched() {
if (this.autoLaunch) {
return app.commandLine.hasSwitch("hidden");
} else {

View 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");

View file

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

View file

@ -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();
};

View file

@ -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 {

View file

@ -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);
};

View file

@ -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;
};

View file

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

View file

@ -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);
};

View file

@ -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();

View file

@ -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));
};

View file

@ -1,7 +1,7 @@
import Store, { Schema } from "electron-store";
interface SafeStorageStore {
encryptionKey: string;
encryptionKey?: string;
}
const safeStorageSchema: Schema<SafeStorageStore> = {

View file

@ -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({

View file

@ -1,7 +1,7 @@
import Store, { Schema } from "electron-store";
interface UserPreferences {
hideDockIcon: boolean;
hideDockIcon?: boolean;
skipAppVersion?: string;
muteUpdateNotificationVersion?: string;
}

View file

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

View file

@ -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);
});
});
};

View file

@ -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 });
};

View file

@ -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
*

View file

@ -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");

View 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 };
};

View file

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

View file

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

View file

@ -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>;
}

View file

@ -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"]

File diff suppressed because it is too large Load diff

View file

@ -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]

View file

@ -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))

View file

@ -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

View file

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

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -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,

View file

@ -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();
}

View file

@ -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"),

View file

@ -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> {

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "配对完成"
}

View file

@ -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 {

View file

@ -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

View file

@ -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)''';
}

View file

@ -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!;
}

View file

@ -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;

View file

@ -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 {

View file

@ -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");

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

View 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;
}
}

View 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
);
}

View file

@ -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;
}
}

View file

@ -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!",
),
]);

View file

@ -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);
}
}
}

View file

@ -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();

View file

@ -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;
}
}
}

View file

@ -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"]);

View file

@ -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();
}
}
}
}

View 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

View file

@ -0,0 +1 @@
include: ../../analysis_options.yaml

View file

@ -0,0 +1,2 @@
export 'src/model.dart';
export 'src/service.dart';

View file

@ -0,0 +1,5 @@
// create enum for type of message for cast
enum CastMessageType {
pairCode,
alreadyCasting,
}

View 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();
}

View 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:

View 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

View file

@ -0,0 +1 @@
include: ../../analysis_options.yaml

View file

@ -0,0 +1 @@
export 'src/service.dart';

View 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();
}
}

View 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:

View 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

View file

@ -0,0 +1 @@
include: ../../analysis_options.yaml

View file

@ -0,0 +1 @@
export 'src/service.dart';

View 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;
}
}

View 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