From 210a13f1bc79679c0aaad7e94e724acc4629a1ec Mon Sep 17 00:00:00 2001 From: Vladimir Y Date: Fri, 27 Jul 2018 02:36:59 +0300 Subject: [PATCH] cleanup, enhane logging, improve auto-login stability --- .editorconfig | 3 + .travis.yml | 78 ++-- README.md | 2 +- appveyor.yml | 32 +- electron-builder.yml | 15 +- package.json | 55 ++- sass-lint.yml | 26 +- src/e2e/index.spec.ts | 67 +-- src/e2e/unread.spec.ts | 66 +++ src/e2e/workflow.ts | 32 +- .../api/endpoints-builders/tray-icon.ts | 1 + src/electron-main/api/index.spec.ts | 17 +- src/electron-main/api/index.ts | 42 +- src/electron-main/constants.ts | 46 +- src/electron-main/database/entity/entities.ts | 42 +- src/electron-main/index.ts | 3 +- src/electron-main/storage-upgrade.ts | 6 + src/electron-main/util.ts | 8 +- .../build-env-based/development.ts | 2 +- .../build-env-based/production.ts | 13 +- src/electron-preload/util.ts | 6 + src/electron-preload/webview/common.ts | 5 - src/electron-preload/webview/constants.ts | 12 + .../webview/protonmail/index.ts | 292 ++++++------ src/electron-preload/webview/stub/index.ts | 1 - .../webview/stub/tsconfig.json | 6 - .../webview/tutanota/index.ts | 178 ++++--- src/electron-preload/webview/util.ts | 100 ++-- src/shared/api/common.ts | 4 + src/shared/api/webview/common.ts | 11 +- src/shared/api/webview/protonmail.ts | 5 +- src/shared/api/webview/tutanota.ts | 5 +- src/shared/constants.ts | 30 +- src/shared/model/account.ts | 4 +- src/shared/model/electron.ts | 16 +- src/shared/model/error.ts | 1 + src/shared/model/options.ts | 75 +-- src/shared/types.ts | 5 + src/shared/util.ts | 47 +- .../src/app/+accounts/account.component.html | 17 +- .../src/app/+accounts/account.component.ts | 439 ++++++++++-------- .../src/app/+accounts/accounts.component.html | 2 - .../src/app/+accounts/accounts.component.scss | 6 +- .../src/app/+accounts/accounts.component.ts | 41 +- src/web/src/app/+accounts/accounts.effects.ts | 104 ++--- src/web/src/app/+accounts/accounts.guard.ts | 17 +- .../+accounts/keepass-request.component.ts | 14 +- src/web/src/app/+core/core.effects.ts | 8 +- src/web/src/app/+core/core.module.ts | 2 +- src/web/src/app/+core/electron.service.ts | 43 +- ...ice.ts => global-error-handler.service.ts} | 0 src/web/src/app/+core/navigation.effects.ts | 63 +-- .../app/+options/account-edit.component.html | 18 +- .../app/+options/account-edit.component.ts | 113 ++--- .../src/app/+options/accounts.component.ts | 5 +- .../app/+options/base-settings.component.html | 18 +- .../app/+options/base-settings.component.ts | 21 +- .../encryption-presets.component.html | 4 +- .../+options/encryption-presets.component.ts | 5 +- .../keepass-associate-settings.component.ts | 5 +- .../+options/keepass-associate.component.ts | 20 +- .../+options/keepass-reference.component.ts | 4 +- src/web/src/app/+options/login.component.ts | 7 +- src/web/src/app/+options/options.effects.ts | 224 +++++---- src/web/src/app/+options/options.service.ts | 2 +- .../app/+options/settings-configure.guard.ts | 7 +- .../app/+options/settings-setup.component.ts | 5 +- .../src/app/+options/settings.component.ts | 2 +- src/web/src/app/+options/storage.component.ts | 11 +- .../src/app/components/app.component.spec.ts | 10 +- .../app/components/error-list.component.ts | 7 +- src/web/src/app/store/actions/accounts.ts | 4 +- src/web/src/app/store/actions/navigation.ts | 4 +- src/web/src/app/store/reducers/accounts.ts | 140 +++--- src/web/src/app/store/reducers/errors.ts | 21 +- src/web/src/app/store/reducers/options.ts | 37 +- src/web/src/app/store/reducers/root.ts | 10 +- src/web/src/app/store/selectors/accounts.ts | 35 ++ src/web/src/app/store/selectors/errors.ts | 9 + src/web/src/app/store/selectors/index.ts | 9 + src/web/src/app/store/selectors/options.ts | 35 ++ src/web/src/util.ts | 38 ++ .../vendor/electron-webview-angular-fix.ts | 6 +- src/web/tsconfig.json | 3 + src/web/typings.d.ts | 3 +- src/webpack/electron-preload.ts | 6 - tsconfig.json | 23 +- ...nt-codelyzer.json => tslint.codelyzer.json | 0 tslint.json | 93 +--- tslint.tslint-consistent-codestyle.json | 16 + tslint.tslint-rules-bunch.json | 94 ++++ yarn.lock | 377 +++++++-------- 92 files changed, 1923 insertions(+), 1643 deletions(-) create mode 100644 src/e2e/unread.spec.ts create mode 100644 src/electron-preload/util.ts delete mode 100644 src/electron-preload/webview/common.ts create mode 100644 src/electron-preload/webview/constants.ts delete mode 100644 src/electron-preload/webview/stub/index.ts delete mode 100644 src/electron-preload/webview/stub/tsconfig.json create mode 100644 src/shared/api/common.ts rename src/web/src/app/+core/{global-error-hander.service.ts => global-error-handler.service.ts} (100%) create mode 100644 src/web/src/app/store/selectors/accounts.ts create mode 100644 src/web/src/app/store/selectors/errors.ts create mode 100644 src/web/src/app/store/selectors/index.ts create mode 100644 src/web/src/app/store/selectors/options.ts create mode 100644 src/web/src/util.ts rename tslint-codelyzer.json => tslint.codelyzer.json (100%) create mode 100644 tslint.tslint-consistent-codestyle.json create mode 100644 tslint.tslint-rules-bunch.json diff --git a/.editorconfig b/.editorconfig index 87bc25c20..a68ac7c28 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,6 +14,9 @@ max_line_length=140 [*.json] indent_size = 2 +[*.yml] +indent_size = 2 + [*.md] max_line_length = off trim_trailing_whitespace = false diff --git a/.travis.yml b/.travis.yml index 6c134c7e0..ea1230785 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,60 +2,60 @@ branches: only: - - master + - master language: node_js node_js: 8 addons: firefox: latest matrix: include: - - os: osx - osx_image: xcode9.0 - - os: linux - sudo: required - dist: trusty - group: stable - addons: - apt: - packages: - - gnome-keyring - - libgnome-keyring-dev - - libsecret-1-dev - - python-gnomekeyring - services: - - docker + - os: osx + osx_image: xcode9.0 + - os: linux + sudo: required + dist: trusty + group: stable + addons: + apt: + packages: + - gnome-keyring + - libgnome-keyring-dev + - libsecret-1-dev + - python-gnomekeyring + services: + - docker env: global: - - CI=true - - NO_AT_BRIDGE=1 - - ELECTRON_CACHE=$HOME/.cache/electron - - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder - - FAILURE_ARCHIVE_FILE=travis-build-id-$TRAVIS_BUILD_ID.tar.gz - - MOZ_HEADLESS=1 + - CI=true + - NO_AT_BRIDGE=1 + - ELECTRON_CACHE=$HOME/.cache/electron + - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder + - FAILURE_ARCHIVE_FILE=travis-build-id-$TRAVIS_BUILD_ID.tar.gz + - MOZ_HEADLESS=1 cache: yarn: true directories: - - node_modules - - $HOME/.cache/electron - - $HOME/.cache/electron-builder - - $HOME/.cache/snapcraft + - node_modules + - $HOME/.cache/electron + - $HOME/.cache/electron-builder + - $HOME/.cache/snapcraft install: - - pip install --user awscli ; export PATH=$PATH:$HOME/.local/bin - - yarn install +- pip install --user awscli ; export PATH=$PATH:$HOME/.local/bin +- yarn install before_script: - - | - if [ "$TRAVIS_OS_NAME" == "linux" ]; then - export DISPLAY=:99.0; sh -e /etc/init.d/xvfb start; sleep 3; - eval $(dbus-launch --sh-syntax); - eval $(echo -n "" | /usr/bin/gnome-keyring-daemon --login); - eval $(/usr/bin/gnome-keyring-daemon --components=secrets --start); - /usr/bin/python -c "import gnomekeyring;gnomekeyring.create_sync('login', '');"; - fi +- | + if [ "$TRAVIS_OS_NAME" == "linux" ]; then + export DISPLAY=:99.0; sh -e /etc/init.d/xvfb start; sleep 3; + eval $(dbus-launch --sh-syntax); + eval $(echo -n "" | /usr/bin/gnome-keyring-daemon --login); + eval $(/usr/bin/gnome-keyring-daemon --components=secrets --start); + /usr/bin/python -c "import gnomekeyring;gnomekeyring.create_sync('login', '');"; + fi script: ./scripts/travis.sh after_failure: - - $(git ls-files -o | grep -Fv -e node_modules -e app -e dist >> failure-files.list) - - tar cvzf $FAILURE_ARCHIVE_FILE $(cat failure-files.list) - - aws --endpoint=$AWS_ENDPOINT_URL s3 cp $FAILURE_ARCHIVE_FILE s3://$AWS_BUCKET +- $(git ls-files -o | grep -Fv -e node_modules -e app -e dist >> failure-files.list) +- tar cvzf $FAILURE_ARCHIVE_FILE $(cat failure-files.list) +- aws --endpoint=$AWS_ENDPOINT_URL s3 cp $FAILURE_ARCHIVE_FILE s3://$AWS_BUCKET notifications: email: on_success: never diff --git a/README.md b/README.md index 6cd8d4424..aa52489ed 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ is built with Electron unofficial desktop app for [ProtonMail](https://protonmai - Regardless of the platform you are working on, you will need to have Node.JS v8 installed. Version 8 is required to match the Node.JS version Electron comes with. If you already have Node.JS installed, but not the version 8, then you might want to use [Node Version Manager](https://github.com/creationix/nvm) to be able to switch between multiple Node.JS versions: - Install [NVM](https://github.com/creationix/nvm). - - Run `nvm instal 8`. + - Run `nvm install 8`. - Run `nvm use 8`. - [keytar](https://github.com/atom/node-keytar) module requires compiling prebuild node files and for that Python and C++ compiler need to be installed on your system: - **`On Windows`**: the simplest way to install all the needed stuff on Windows is to run `npm install --global --production windows-build-tools` CLI command. diff --git a/appveyor.yml b/appveyor.yml index 8f89ef72f..37be8056a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -2,29 +2,29 @@ branches: only: - - master + - master skip_tags: true os: unstable #environment: # matrix: # - nodejs_version: 8 platform: - - x64 +- x64 cache: - - "%LOCALAPPDATA%/Yarn" - - "%USERPROFILE%/.electron" - - "%USERPROFILE%/.electron-builder" +- "%LOCALAPPDATA%/Yarn" +- "%USERPROFILE%/.electron" +- "%USERPROFILE%/.electron-builder" test: off install: - # - ps: Install-Product node $env:nodejs_version $env:platform - - ps: Install-Product node 8 $env:platform - - SET CI=true - - npm install --global yarn - - set PATH=%PATH%;C:\.yarn\bin - - node --version - - npm --version - - yarn --version - - yarn install --mutex file +# - ps: Install-Product node $env:nodejs_version $env:platform +- ps: Install-Product node 8 $env:platform +- SET CI=true +- npm install --global yarn +- set PATH=%PATH%;C:\.yarn\bin +- node --version +- npm --version +- yarn --version +- yarn install --mutex file build_script: - - yarn run app:dist - - yarn run electron-builder:publish:x64 +- yarn run app:dist +- yarn run electron-builder:publish:x64 diff --git a/electron-builder.yml b/electron-builder.yml index 39f35cf77..b1041e5d5 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -16,12 +16,13 @@ files: [ "!node_modules/rxjs/{_esm5,_esm2015,src,bundles}", # TODO sodium-native: include into the package only needed prebuilds for the platform is being built "!node_modules/sodium-native/{src,test,libsodium}", + # TODO exclude not needed stuff in "files:" section to reduce app packages size, can save megabytes, so a significant improvement ] compression: maximum asar: true asarUnpack: - - "**/node_modules/sodium-native/**/*" - - "**/node_modules/keytar/**/*" +- "**/node_modules/sodium-native/**/*" +- "**/node_modules/keytar/**/*" mac: icon: ./app/assets/icons/mac/icon.icns @@ -31,8 +32,8 @@ dmg: icon: ./app/assets/icons/mac/icon.icns iconSize: 128 contents: - - {x: 380, y: 240, type: link, path: /Applications} - - {x: 122, y: 240, type: file} + - {x: 380, y: 240, type: link, path: /Applications} + - {x: 122, y: 240, type: file} linux: icon: ./app/assets/icons/png @@ -43,8 +44,8 @@ win: artifactName: ${name}-${version}-windows-${arch}.${ext} icon: ./app/assets/icons/win/icon.ico target: - - {target: nsis} - - {target: portable} + - {target: nsis} + - {target: portable} nsis: artifactName: ${name}-${version}-windows-${arch}-nsis-installer.${ext} @@ -52,4 +53,4 @@ nsis: perMachine: false portable: - artifactName: ${name}-${version}-windows-${arch}-portable.${ext} + artifactName: ${name}-${version}-windows-${arch}-portable.${ext} diff --git a/package.json b/package.json index 940c38cc7..d2a8fcfa5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "email-securely-app", "description": "Unofficial desktop app for E2E encrypted email providers", - "version": "1.2.0", + "version": "1.3.0", "author": "Vladimir Yakovlev ", "license": "MIT", "homepage": "https://github.com/vladimiry/email-securely-app", @@ -48,7 +48,7 @@ "lint": "npm-run-all lint:sass lint:code", "lint:code": "npm-run-all lint:js lint:ts", "lint:js": "tslint \"./src/**/*.js\" \"./*.js\"", - "lint:sass": "sass-lint -v -q -c sass-lint.yml", + "lint:sass": "sass-lint -v -q -c ./sass-lint.yml", "lint:ts": "tslint -p ./tsconfig.json \"./src/**/*.ts\" \"./src/**/*.js\"", "security-check": "nsp check --preprocessor yarn --reporter table", "start": "npm-run-all build:app start:electron-main", @@ -76,7 +76,7 @@ "class-validator": "0.9.1", "compare-versions": "3.3.0", "electron-log": "2.2.16", - "electron-rpc-api": "1.0.0", + "electron-rpc-api": "2.0.0", "electron-unhandled": "1.1.0", "electron-updater": "3.0.3", "fs-json-store": "2.0.2", @@ -84,7 +84,7 @@ "jimp": "0.2.28", "keepasshttp-client": "2.2.6", "keytar": "4.2.1", - "nano-sql": "https://github.com/vladimiry/Nano-SQL#lib", + "nano-sql": "1.7.3", "ramda": "0.25.0", "reflect-metadata": "0.1.12", "rolling-rate-limiter": "0.1.11", @@ -92,31 +92,30 @@ "valid-url": "1.0.9" }, "devDependencies": { - "@angular-devkit/build-optimizer": "0.7.0-beta.1", - "@angular-devkit/core": "0.7.0-beta.1", - "@angular/animations": "6.0.9", - "@angular/common": "6.0.9", - "@angular/compiler": "6.0.9", - "@angular/compiler-cli": "6.0.9", - "@angular/core": "6.0.9", - "@angular/forms": "6.0.9", - "@angular/http": "6.0.9", - "@angular/language-service": "6.0.9", - "@angular/platform-browser": "6.0.9", - "@angular/platform-browser-dynamic": "6.0.9", - "@angular/router": "6.0.9", + "@angular-devkit/build-optimizer": "0.7.1", + "@angular-devkit/core": "0.7.1", + "@angular/animations": "6.1.0", + "@angular/common": "6.1.0", + "@angular/compiler": "6.1.0", + "@angular/compiler-cli": "6.1.0", + "@angular/core": "6.1.0", + "@angular/forms": "6.1.0", + "@angular/http": "6.1.0", + "@angular/language-service": "6.1.0", + "@angular/platform-browser": "6.1.0", + "@angular/platform-browser-dynamic": "6.1.0", + "@angular/router": "6.1.0", "@angularclass/hmr": "2.1.3", "@ng-select/ng-select": "2.3.5", "@ngrx/effects": "6.0.1", "@ngrx/router-store": "6.0.1", "@ngrx/store": "6.0.1", - "@ngtools/webpack": "6.1.0-beta.1", + "@ngtools/webpack": "6.1.1", "@types/glob": "5.0.35", - "@types/html-webpack-plugin": "2.30.4", + "@types/html-webpack-plugin": "3.2.0", "@types/jasmine": "2.8.8", "@types/karma": "1.7.5", "@types/keytar": "4.0.1", - "@types/lodash-es": "4.17.0", "@types/mini-css-extract-plugin": "0.2.0", "@types/mkdirp": "0.5.2", "@types/node": "8.10.11", @@ -131,14 +130,14 @@ "@types/uglifyjs-webpack-plugin": "1.1.0", "@types/valid-url": "1.0.2", "@types/webdriverio": "4.10.3", - "@types/webpack": "4.4.7", + "@types/webpack": "4.4.8", "@types/webpack-dev-server": "2.9.5", "@types/webpack-env": "1.13.6", "@types/webpack-merge": "4.1.3", "@types/webpack-node-externals": "1.6.3", "ava": "1.0.0-beta.6", "awesome-typescript-loader": "5.2.0", - "bootstrap": "4.1.2", + "bootstrap": "4.1.3", "cache-loader": "1.2.2", "circular-dependency-plugin": "5.0.2", "codelyzer": "4.4.2", @@ -147,10 +146,10 @@ "cross-env": "5.2.0", "cross-spawn": "6.0.5", "css-loader": "1.0.0", - "cssnano": "4.0.3", + "cssnano": "4.0.4", "devtron": "1.4.0", "electron": "2.0.5", - "electron-builder": "20.24.4", + "electron-builder": "20.26.0", "exports-loader": "0.7.0", "file-loader": "1.1.11", "font-awesome": "4.7.0", @@ -159,7 +158,7 @@ "html-webpack-plugin": "4.0.0-alpha", "immer": "1.3.1", "jasmine": "3.1.0", - "karma": "2.0.4", + "karma": "2.0.5", "karma-chrome-launcher": "2.2.0", "karma-firefox-launcher": "1.1.0", "karma-jasmine": "1.1.2", @@ -168,7 +167,6 @@ "karma-webpack": "3.0.0", "keysim": "2.1.0", "less-loader": "4.1.0", - "lodash-es": "4.17.10", "mini-css-extract-plugin": "0.4.1", "mkdirp": "0.5.1", "ngx-bootstrap": "3.0.1", @@ -177,7 +175,7 @@ "nsp": "3.2.1", "nsp-preprocessor-yarn": "1.1.2", "null-loader": "0.1.1", - "otplib": "10.0.0", + "otplib": "10.0.1", "postcss-custom-properties": "7.0.0", "postcss-loader": "2.1.6", "postcss-url": "7.3.2", @@ -204,13 +202,14 @@ "tsconfig-paths": "3.4.2", "tsconfig-paths-webpack-plugin": "3.2.0", "tslint": "5.11.0", + "tslint-consistent-codestyle": "1.13.3", "tslint-rules-bunch": "0.0.4", "typescript": "2.9.2", "uglifyjs-webpack-plugin": "1.2.7", "unionize": "https://github.com/vladimiry/unionize#add-tagprefix-option-lib", "url-loader": "1.0.1", "wait-on": "2.1.0", - "webpack": "4.16.1", + "webpack": "4.16.2", "webpack-cli": "3.1.0", "webpack-dev-server": "3.1.5", "webpack-merge": "4.1.3", diff --git a/sass-lint.yml b/sass-lint.yml index 82534e30e..ecd23de49 100644 --- a/sass-lint.yml +++ b/sass-lint.yml @@ -2,7 +2,7 @@ options: formatter: stylish files: include: - - "./src/web/**/*.s+(a|c)ss" + - "./src/web/src/**/*.s+(a|c)ss" rules: # extends extends-before-mixins: 1 @@ -58,24 +58,24 @@ rules: clean-import-paths: 1 empty-args: 1 hex-length: - - 1 - - style: long + - 1 + - style: long hex-notation: - - 1 - - style: lowercase + - 1 + - style: lowercase indentation: - - 1 - - size: 4 + - 1 + - size: 4 leading-zero: - - 1 - - include: true + - 1 + - include: true nesting-depth: - - 1 - - max-depth: 10 + - 1 + - max-depth: 10 property-sort-order: 0 quotes: - - 1 - - style: double + - 1 + - style: double shorthand-values: 1 url-quotes: 1 variable-for-property: 1 diff --git a/src/e2e/index.spec.ts b/src/e2e/index.spec.ts index 8c1024c19..c35dc71fe 100644 --- a/src/e2e/index.spec.ts +++ b/src/e2e/index.spec.ts @@ -1,25 +1,12 @@ -// TODO enable "tslint:await-promise" rule when spectron gets proper declaration files (all async methods return promises) +// TODO remove the "tslint:disable:await-promise" when spectron gets proper declaration files +// TODO track this issue https://github.com/DefinitelyTyped/DefinitelyTyped/issues/25186 // tslint:disable:await-promise import fs from "fs"; import path from "path"; import {promisify} from "util"; -import { - ONE_SECOND_MS, - RUNTIME_ENV_E2E_PROTONMAIL_2FA_CODE, - RUNTIME_ENV_E2E_PROTONMAIL_LOGIN, - RUNTIME_ENV_E2E_PROTONMAIL_PASSWORD, - RUNTIME_ENV_E2E_PROTONMAIL_UNREAD_MIN, - RUNTIME_ENV_E2E_TUTANOTA_2FA_CODE, - RUNTIME_ENV_E2E_TUTANOTA_LOGIN, - RUNTIME_ENV_E2E_TUTANOTA_PASSWORD, - RUNTIME_ENV_E2E_TUTANOTA_UNREAD_MIN, -} from "src/shared/constants"; -import {accountBadgeCssSelector, ENV, initApp, test} from "./workflow"; -import {AccountType} from "src/shared/model/account"; - -const {CI} = process.env; +import {ENV, initApp, test} from "./workflow"; test.serial("general actions: app start, master password setup, add accounts, logout, auto login", async (t) => { // setup and login @@ -60,51 +47,3 @@ test.serial("general actions: app start, master password setup, add accounts, lo const rawSettings = promisify(fs.readFile)(path.join(t.context.userDataDirPath, "settings.bin")); t.true(rawSettings.toString().indexOf(ENV.loginPrefix) === -1); }); - -for (const {type, login, password, twoFactorCode, unread} of ([ - { - type: "protonmail", - login: process.env[RUNTIME_ENV_E2E_PROTONMAIL_LOGIN], - password: process.env[RUNTIME_ENV_E2E_PROTONMAIL_PASSWORD], - twoFactorCode: process.env[RUNTIME_ENV_E2E_PROTONMAIL_2FA_CODE], - unread: Number(process.env[RUNTIME_ENV_E2E_PROTONMAIL_UNREAD_MIN]), - }, - { - type: "tutanota", - login: process.env[RUNTIME_ENV_E2E_TUTANOTA_LOGIN], - password: process.env[RUNTIME_ENV_E2E_TUTANOTA_PASSWORD], - twoFactorCode: process.env[RUNTIME_ENV_E2E_TUTANOTA_2FA_CODE], - unread: Number(process.env[RUNTIME_ENV_E2E_TUTANOTA_UNREAD_MIN]), - }, -] as Array<{ type: AccountType, login: string, password: string, twoFactorCode: string, unread: number }>)) { - if (!login || !password || !unread || isNaN(unread)) { - continue; - } - - test.serial(`unread check: ${type}`, async (t) => { - const workflow = await initApp(t, {initial: true}); - const pauseMs = ONE_SECOND_MS * (type === "tutanota" ? (CI ? 80 : 40) : 20); - const unreadBadgeSelector = accountBadgeCssSelector(); - const state: { parsedUnreadText?: string } = {}; - - await workflow.login({setup: true, savePassword: false}); - await workflow.addAccount({type, login, password, twoFactorCode}); - await workflow.selectAccount(); - - await t.context.app.client.pause(pauseMs); - - try { - try { - state.parsedUnreadText = String(await t.context.app.client.getText(unreadBadgeSelector)); - } catch (e) { - t.fail(`failed to locate DOM element by "${unreadBadgeSelector}" selector after the "${pauseMs}" milliseconds pause`); - throw e; - } - - const parsedUnread = Number(state.parsedUnreadText.replace(/\D/g, "")); - t.true(parsedUnread >= unread, `parsedUnread(${parsedUnread}) >= unread(${unread})`); - } finally { - await workflow.destroyApp(); - } - }); -} diff --git a/src/e2e/unread.spec.ts b/src/e2e/unread.spec.ts new file mode 100644 index 000000000..45be7592b --- /dev/null +++ b/src/e2e/unread.spec.ts @@ -0,0 +1,66 @@ +// TODO remove the "tslint:disable:await-promise" when spectron gets proper declaration files +// TODO track this issue https://github.com/DefinitelyTyped/DefinitelyTyped/issues/25186 +// tslint:disable:await-promise + +import {accountBadgeCssSelector, CI, initApp, test} from "./workflow"; +import {AccountType} from "src/shared/model/account"; +import {ONE_SECOND_MS} from "src/shared/constants"; + +// protonmail account to login during e2e tests running +const RUNTIME_ENV_E2E_PROTONMAIL_LOGIN = `EMAIL_SECURELY_APP_E2E_PROTONMAIL_LOGIN`; +const RUNTIME_ENV_E2E_PROTONMAIL_PASSWORD = `EMAIL_SECURELY_APP_E2E_PROTONMAIL_PASSWORD`; +const RUNTIME_ENV_E2E_PROTONMAIL_2FA_CODE = `EMAIL_SECURELY_APP_E2E_PROTONMAIL_2FA_CODE`; +const RUNTIME_ENV_E2E_PROTONMAIL_UNREAD_MIN = `EMAIL_SECURELY_APP_E2E_PROTONMAIL_UNREAD_MIN`; +// tutanota account to login during e2e tests running +const RUNTIME_ENV_E2E_TUTANOTA_LOGIN = `EMAIL_SECURELY_APP_E2E_TUTANOTA_LOGIN`; +const RUNTIME_ENV_E2E_TUTANOTA_PASSWORD = `EMAIL_SECURELY_APP_E2E_TUTANOTA_PASSWORD`; +const RUNTIME_ENV_E2E_TUTANOTA_2FA_CODE = `EMAIL_SECURELY_APP_E2E_TUTANOTA_2FA_CODE`; +const RUNTIME_ENV_E2E_TUTANOTA_UNREAD_MIN = `EMAIL_SECURELY_APP_E2E_TUTANOTA_UNREAD_MIN`; + +for (const {type, login, password, twoFactorCode, unread} of ([ + { + type: "protonmail", + login: process.env[RUNTIME_ENV_E2E_PROTONMAIL_LOGIN], + password: process.env[RUNTIME_ENV_E2E_PROTONMAIL_PASSWORD], + twoFactorCode: process.env[RUNTIME_ENV_E2E_PROTONMAIL_2FA_CODE], + unread: Number(process.env[RUNTIME_ENV_E2E_PROTONMAIL_UNREAD_MIN]), + }, + { + type: "tutanota", + login: process.env[RUNTIME_ENV_E2E_TUTANOTA_LOGIN], + password: process.env[RUNTIME_ENV_E2E_TUTANOTA_PASSWORD], + twoFactorCode: process.env[RUNTIME_ENV_E2E_TUTANOTA_2FA_CODE], + unread: Number(process.env[RUNTIME_ENV_E2E_TUTANOTA_UNREAD_MIN]), + }, +] as Array<{ type: AccountType, login: string, password: string, twoFactorCode: string, unread: number }>)) { + if (!login || !password || !unread || isNaN(unread)) { + continue; + } + + test.serial(`unread check: ${type}`, async (t) => { + const workflow = await initApp(t, {initial: true}); + const pauseMs = ONE_SECOND_MS * (type === "tutanota" ? (CI ? 80 : 40) : 20); + const unreadBadgeSelector = accountBadgeCssSelector(); + const state: { parsedUnreadText?: string } = {}; + + await workflow.login({setup: true, savePassword: false}); + await workflow.addAccount({type, login, password, twoFactorCode}); + await workflow.selectAccount(); + + await t.context.app.client.pause(pauseMs); + + try { + try { + state.parsedUnreadText = await t.context.app.client.getText(unreadBadgeSelector); + } catch (e) { + t.fail(`failed to locate DOM element by "${unreadBadgeSelector}" selector after the "${pauseMs}" milliseconds pause`); + throw e; + } + + const parsedUnread = Number(state.parsedUnreadText.replace(/\D/g, "")); + t.true(parsedUnread >= unread, `parsedUnread(${parsedUnread}) >= unread(${unread})`); + } finally { + await workflow.destroyApp(); + } + }); +} diff --git a/src/e2e/workflow.ts b/src/e2e/workflow.ts index d71acc0b3..feb904bc2 100644 --- a/src/e2e/workflow.ts +++ b/src/e2e/workflow.ts @@ -1,4 +1,5 @@ -// TODO enable "tslint:await-promise" rule when spectron gets proper declaration files (all async methods return promises) +// TODO remove the "tslint:disable:await-promise" when spectron gets proper declaration files +// TODO track this issue https://github.com/DefinitelyTyped/DefinitelyTyped/issues/25186 // tslint:disable:await-promise import ava, {ExecutionContext, TestInterface} from "ava"; @@ -8,13 +9,13 @@ import mkdirp from "mkdirp"; import path from "path"; import psNode from "ps-node"; // see also https://www.npmjs.com/package/find-process import psTree from "ps-tree"; -import randomString from "randomstring"; import sinon from "sinon"; import {Application} from "spectron"; import {promisify} from "util"; import {AccountType} from "src/shared/model/account"; import {ACCOUNTS_CONFIG, ONE_SECOND_MS, RUNTIME_ENV_E2E, RUNTIME_ENV_USER_DATA_DIR} from "src/shared/constants"; +import randomString from "randomstring"; export interface TestContext { app: Application; @@ -29,17 +30,16 @@ export interface TestContext { } export const test = ava as TestInterface; - -const {CI} = process.env; -const rootDirPath = path.resolve(__dirname, process.cwd()); -const appDirPath = path.join(rootDirPath, "./app"); -const mainScriptFilePath = path.join(appDirPath, "./electron-main.js"); - export const ENV = { masterPassword: `master-password-${randomString.generate({length: 8})}`, loginPrefix: `login-${randomString.generate({length: 8})}`, }; -export const CONF = { +export const {CI} = process.env; + +const rootDirPath = path.resolve(__dirname, process.cwd()); +const appDirPath = path.join(rootDirPath, "./app"); +const mainScriptFilePath = path.join(appDirPath, "./electron-main.js"); +const CONF = { timeouts: { element: ONE_SECOND_MS, elementTouched: ONE_SECOND_MS * 0.3, @@ -47,7 +47,7 @@ export const CONF = { transition: ONE_SECOND_MS * (CI ? 1 : 0.3), }, }; -export const GLOBAL_STATE = { +const GLOBAL_STATE = { loginPrefixCount: 0, }; @@ -313,12 +313,14 @@ function buildWorkflow(t: ExecutionContext) { // making sure modal is opened (consider testing by url) await client.waitForVisible(listGroupSelector); - if (typeof index !== "undefined") { - await client.click(`${listGroupSelector} .list-group-item-action:nth-child(${index + 1})`); + if (typeof index === "undefined") { + return; + } - if (index === 0) { - await client.waitForVisible(`.modal-body email-securely-app-accounts`); - } + await client.click(`${listGroupSelector} .list-group-item-action:nth-child(${index + 1})`); + + if (index === 0) { + await client.waitForVisible(`.modal-body email-securely-app-accounts`); } }, diff --git a/src/electron-main/api/endpoints-builders/tray-icon.ts b/src/electron-main/api/endpoints-builders/tray-icon.ts index ba87b89e6..9fe68f019 100644 --- a/src/electron-main/api/endpoints-builders/tray-icon.ts +++ b/src/electron-main/api/endpoints-builders/tray-icon.ts @@ -13,6 +13,7 @@ export async function buildEndpoints( const icons = await prepareTrayIcons(ctx.locations); return { + // TODO replace Jimp with something more lightweight and easier to use updateOverlayIcon: ({hasLoggedOut, unread}) => from((async () => { const browserWindow = ctx.uiContext && ctx.uiContext.browserWindow; const tray = ctx.uiContext && ctx.uiContext.tray; diff --git a/src/electron-main/api/index.spec.ts b/src/electron-main/api/index.spec.ts index c56d816ea..ed7c575fe 100644 --- a/src/electron-main/api/index.spec.ts +++ b/src/electron-main/api/index.spec.ts @@ -12,11 +12,11 @@ import {AccountConfigCreatePatch, AccountConfigUpdatePatch, PasswordFieldContain import {BaseConfig, Config, Settings} from "src/shared/model/options"; import {buildSettingsAdapter, initContext} from "src/electron-main/util"; import {Context} from "src/electron-main/model"; +import {DatabaseUpsertInput} from "src/shared/api/main"; import {Endpoints} from "src/shared/api/main"; import {INITIAL_STORES, KEYTAR_MASTER_PASSWORD_ACCOUNT, KEYTAR_SERVICE_NAME} from "src/electron-main/constants"; import {pickBaseConfigProperties} from "src/shared/util"; import {StatusCode, StatusCodeError} from "src/shared/model/error"; -import {DatabaseUpsertInput} from "../../shared/api/main"; // TODO "immer" instead of cloning with "..." @@ -247,6 +247,7 @@ const tests: Record) => Imple closeToTray: false, unreadNotifications: true, checkForUpdatesAndNotify: true, + logLevel: "warn", }, { startMinimized: true, @@ -254,6 +255,7 @@ const tests: Record) => Imple closeToTray: true, unreadNotifications: false, checkForUpdatesAndNotify: false, + logLevel: "info", }, ]; @@ -319,9 +321,8 @@ const tests: Record) => Imple }, removeAccount: async (t) => { - const endpoints = t.context.endpoints; - const addHandler = endpoints.addAccount; - const removeHandler = endpoints.removeAccount; + const {endpoints} = t.context; + const {addAccount, removeAccount} = endpoints; const addProtonPayload: Readonly> = { type: "protonmail", login: "login1", @@ -349,15 +350,15 @@ const tests: Record) => Imple await readConfigAndSettings(endpoints, {password: OPTIONS.masterPassword}); try { - await removeHandler(removePayload404).toPromise(); + await removeAccount(removePayload404).toPromise(); } catch ({message}) { t.is(message, `Account with "${removePayload404.login}" login has not been found`, "404 account"); } - await addHandler(addProtonPayload).toPromise(); - await addHandler(addTutaPayload).toPromise(); + await addAccount(addProtonPayload).toPromise(); + await addAccount(addTutaPayload).toPromise(); const settings = await t.context.ctx.settingsStore.readExisting(); - const updatedSettings = await removeHandler(removePayload).toPromise(); + const updatedSettings = await removeAccount(removePayload).toPromise(); const expectedSettings = { ...settings, _rev: (settings._rev as number) + 1, diff --git a/src/electron-main/api/index.ts b/src/electron-main/api/index.ts index 0e02f243b..e9aa20904 100644 --- a/src/electron-main/api/index.ts +++ b/src/electron-main/api/index.ts @@ -1,13 +1,15 @@ import keytar from "keytar"; +import logger from "electron-log"; import {EMPTY, from} from "rxjs"; import {AccountConfig} from "src/shared/model/account"; -import {Database, General, KeePass, TrayIcon} from "./endpoints-builders"; import {buildSettingsAdapter} from "src/electron-main/util"; +import {Config} from "src/shared/model/options"; import {Context} from "src/electron-main/model"; +import {Database, General, KeePass, TrayIcon} from "./endpoints-builders"; import {Endpoints, IPC_MAIN_API} from "src/shared/api/main"; -import {findExistingAccountConfig} from "src/shared/util"; import {KEYTAR_MASTER_PASSWORD_ACCOUNT, KEYTAR_SERVICE_NAME} from "src/electron-main/constants"; +import {pickAccountStrict} from "src/shared/util"; import {upgradeConfig, upgradeSettings} from "src/electron-main/storage-upgrade"; export const initApi = async (ctx: Context): Promise => { @@ -18,7 +20,6 @@ export const initApi = async (ctx: Context): Promise => { ...await TrayIcon.buildEndpoints(ctx), addAccount: ({type, login, entryUrl, storeMails, credentials, credentialsKeePass}) => from((async () => { - const settings = await ctx.settingsStore.readExisting(); const account = { type, login, @@ -27,6 +28,7 @@ export const initApi = async (ctx: Context): Promise => { credentials, credentialsKeePass, } as AccountConfig; // TODO ger rid of "TS as" casting + const settings = await ctx.settingsStore.readExisting(); settings.accounts.push(account); @@ -65,28 +67,34 @@ export const initApi = async (ctx: Context): Promise => { return EMPTY.toPromise(); })()), + // TODO update "patchBaseSettings" api method test ("logLevel" value, "logger.transports.file.level" updpate) patchBaseSettings: (patch) => from((async () => { - const config = await ctx.configStore.readExisting(); - const actualPatch = JSON.parse(JSON.stringify(patch)); + const config = await ctx.configStore.write({ + ...(await ctx.configStore.readExisting()), + ...JSON.parse(JSON.stringify(patch)), // parse => stringify call strips out undefined values from the object + }); + + logger.transports.file.level = config.logLevel; - return await ctx.configStore.write({...config, ...actualPatch}); + return config; })()), - // TODO update "readConfig" api method test (upgradeConfig) + // TODO update "readConfig" api method test ("upgradeConfig" call, "logger.transports.file.level" updpate) readConfig: () => from((async () => { - const config = await ctx.configStore.read(); + let config: Config | null = await ctx.configStore.read(); - if (config) { - if (upgradeConfig(config)) { - return ctx.configStore.write(config); - } - return config; + if (!config) { + config = await ctx.configStore.write(ctx.initialStores.config); + } else if (upgradeConfig(config)) { + config = await ctx.configStore.write(config); } - return ctx.configStore.write(ctx.initialStores.config); + logger.transports.file.level = config.logLevel; + + return config; })()), - // TODO update "readSettings" api method test, "upgradeSettings" and "no password provided" cases + // TODO update "readSettings" api method test ("upgradeSettings" call, "no password provided" case) readSettings: ({password, savePassword}) => from((async () => { // trying to auto login if (!password) { @@ -138,7 +146,7 @@ export const initApi = async (ctx: Context): Promise => { removeAccount: ({login}) => from((async () => { const settings = await ctx.settingsStore.readExisting(); - const account = findExistingAccountConfig(settings.accounts, login); + const account = pickAccountStrict(settings.accounts, {login}); const index = settings.accounts.indexOf(account); settings.accounts.splice(index, 1); @@ -156,7 +164,7 @@ export const initApi = async (ctx: Context): Promise => { // TODO update "updateAccount" api method test (entryUrl, changed credentials structure) updateAccount: ({login, entryUrl, storeMails, credentials, credentialsKeePass}) => from((async () => { const settings = await ctx.settingsStore.readExisting(); - const account = findExistingAccountConfig(settings.accounts, login); + const account = pickAccountStrict(settings.accounts, {login}); const {credentials: existingCredentials, credentialsKeePass: existingCredentialsKeePass} = account; if (typeof storeMails !== "undefined") { diff --git a/src/electron-main/constants.ts b/src/electron-main/constants.ts index 70745ee00..22b791bbd 100644 --- a/src/electron-main/constants.ts +++ b/src/electron-main/constants.ts @@ -1,19 +1,25 @@ +import {LogLevel} from "electron-log"; import {Options as EncryptionAdapterOptions} from "fs-json-store-encryption-adapter"; import {APP_NAME} from "src/shared/constants"; +import {Config, Settings} from "src/shared/model/options"; +import {ENCRYPTION_DERIVATION_PRESETS, KEY_DERIVATION_PRESETS} from "../shared/model/options"; +import {Model as StoreModel} from "fs-json-store"; export const KEYTAR_SERVICE_NAME = APP_NAME; export const KEYTAR_MASTER_PASSWORD_ACCOUNT = "master-password"; -export const INITIAL_STORES = (() => { +export const INITIAL_STORES: { config: Config; settings: Settings; } = (() => { const encryptionPreset: EncryptionAdapterOptions = { keyDerivation: {type: "sodium.crypto_pwhash", preset: "mode:interactive|algorithm:default"}, encryption: {type: "sodium.crypto_secretbox_easy", preset: "algorithm:default"}, }; + const logLevel: LogLevel = "error"; return Object.freeze({ config: { encryptionPreset, + logLevel, startMinimized: true, compactLayout: false, closeToTray: true, @@ -26,3 +32,41 @@ export const INITIAL_STORES = (() => { settings: {accounts: []}, }); })(); + +export const configEncryptionPresetValidator: StoreModel.StoreValidator = async (data) => { + const keyDerivation = data.encryptionPreset.keyDerivation; + const encryption = data.encryptionPreset.encryption; + + const errors = [ + ...(Object.values(KEY_DERIVATION_PRESETS) + .some((value) => value.type === keyDerivation.type && value.preset === keyDerivation.preset) + ? [] + : [`Wrong "config.encryptionPreset.keyDerivation"="${keyDerivation}" value.`]), + ...(Object.values(ENCRYPTION_DERIVATION_PRESETS) + .some((value) => value.type === encryption.type && value.preset === encryption.preset) + ? [] + : [`Wrong "config.encryptionPreset.encryption"="${encryption}" value.`]), + ]; + + return Promise.resolve( + errors.length + ? errors.join(" ") + : null, + ); +}; + +export const settingsAccountLoginUniquenessValidator: StoreModel.StoreValidator = async (data) => { + const duplicatedLogins = data.accounts + .map((account) => account.login) + .reduce((duplicated: string[], el, i, logins) => { + if (logins.indexOf(el) !== i && duplicated.indexOf(el) === -1) { + duplicated.push(el); + } + return duplicated; + }, []); + const result = duplicatedLogins.length + ? `Duplicate accounts identified. Duplicated logins: ${duplicatedLogins.join(", ")}.` + : null; + + return Promise.resolve(result); +}; diff --git a/src/electron-main/database/entity/entities.ts b/src/electron-main/database/entity/entities.ts index 5709360f4..548be174c 100644 --- a/src/electron-main/database/entity/entities.ts +++ b/src/electron-main/database/entity/entities.ts @@ -14,39 +14,39 @@ export abstract class Base implements DatabaseModel.Base { @IsNotEmpty() @IsString() @Column({type: "string"}) - raw: string; + raw!: string; } export class BasePersisted extends Base implements DatabaseModel.BasePersisted { @IsNotEmpty() @IsString() @Column({type: "string", props: ["pk()"]}) - pk: string; + pk!: string; @IsIn(((typesMap: Record) => Object.keys(typesMap))({protonmail: null, tutanota: null})) @IsNotEmpty() @IsString() @Column({type: "string"}) - type: AccountType; + type!: AccountType; @IsNotEmpty() @IsString() @Column({type: "string"}) - login: string; + login!: string; @IsNotEmpty() @IsString() @Column({type: "string"}) - id: string; + id!: string; } class MailAddress extends Base implements DatabaseModel.MailAddress { @IsNotEmpty() @IsString() - address: string; + address!: string; @IsString() - name: string; + name!: string; } class File extends Base implements DatabaseModel.File { @@ -54,75 +54,75 @@ class File extends Base implements DatabaseModel.File { mimeType?: string; @IsString() - name: string; + name!: string; @IsNotEmpty() @IsInt() - size: number; + size!: number; } class Folder extends Base implements DatabaseModel.Folder { @IsNotEmpty() @IsInt() - type: DatabaseModel.MailFolderTypeValue; + type!: DatabaseModel.MailFolderTypeValue; @IsString() - name: string; + name!: string; } export class Mail extends BasePersisted implements DatabaseModel.Mail { @IsNotEmpty() @IsInt() @Column({type: "int"}) - date: Timestamp; + date!: Timestamp; @IsNotEmpty() @IsString() @Column({type: "string"}) - subject: string; + subject!: string; @IsString() @Column({type: "string"}) - body: string; + body!: string; @IsNotEmpty() @ValidateNested() @Type(() => Folder) @Column({type: "map"}) - folder: Folder; + folder!: Folder; @IsNotEmpty() @ValidateNested() @Type(() => MailAddress) @Column({type: "map"}) - sender: MailAddress; + sender!: MailAddress; @ValidateNested() @IsArray() @Type(() => MailAddress) @Column({type: "array"}) - toRecipients: MailAddress[]; + toRecipients!: MailAddress[]; @ValidateNested() @IsArray() @Type(() => MailAddress) @Column({type: "array"}) - ccRecipients: MailAddress[]; + ccRecipients!: MailAddress[]; @ValidateNested() @IsArray() @Type(() => MailAddress) @Column({type: "array"}) - bccRecipients: MailAddress[]; + bccRecipients!: MailAddress[]; @ValidateNested() @IsArray() @Type(() => File) @Column({type: "array"}) - attachments: File[]; + attachments!: File[]; @IsNotEmpty() @IsBoolean() @Column({type: "bool"}) - unread: boolean; + unread!: boolean; } diff --git a/src/electron-main/index.ts b/src/electron-main/index.ts index 08837f6f8..0096c4a3a 100644 --- a/src/electron-main/index.ts +++ b/src/electron-main/index.ts @@ -15,9 +15,10 @@ import {isWebViewSrcWhitelisted} from "src/shared/util"; electronUnhandled({logger: logger.error}); -// needs for desktop notifications properly working on Win 10, details https://www.electron.build/configuration/nsis +// needed for desktop notifications properly working on Win 10, details https://www.electron.build/configuration/nsis app.setAppUserModelId(`com.github.vladimiry.${APP_NAME}`); +// possible rejection will be caught and logged by above initialized "electron-unhandled" // tslint:disable-next-line:no-floating-promises initContext().then(initApp); diff --git a/src/electron-main/storage-upgrade.ts b/src/electron-main/storage-upgrade.ts index 1ba2eb5b5..d008f193f 100644 --- a/src/electron-main/storage-upgrade.ts +++ b/src/electron-main/storage-upgrade.ts @@ -2,6 +2,7 @@ import compareVersions from "compare-versions"; import {APP_VERSION} from "src/shared/constants"; import {Config, Settings} from "src/shared/model/options"; +import {INITIAL_STORES} from "./constants"; const CONFIG_UPGRADES: Record void> = { "1.1.0": (config) => { @@ -9,6 +10,11 @@ const CONFIG_UPGRADES: Record void> = { delete (config as any).appVersion; } }, + "1.2.0": (config) => { + if (typeof config.logLevel === "undefined") { + config.logLevel = INITIAL_STORES.config.logLevel; + } + }, }; const SETTINGS_UPGRADES: Record void> = { diff --git a/src/electron-main/util.ts b/src/electron-main/util.ts index 4b1530b3b..c3dea53cd 100644 --- a/src/electron-main/util.ts +++ b/src/electron-main/util.ts @@ -8,10 +8,10 @@ import {EncryptionAdapter} from "fs-json-store-encryption-adapter"; import {Fs as StoreFs, Model as StoreModel, Store} from "fs-json-store"; import {BuildEnvironment} from "src/shared/model/common"; -import {Config, configEncryptionPresetValidator, Settings, settingsAccountLoginUniquenessValidator} from "src/shared/model/options"; +import {Config, Settings} from "src/shared/model/options"; +import {configEncryptionPresetValidator, INITIAL_STORES, settingsAccountLoginUniquenessValidator} from "./constants"; import {Context, ContextInitOptions, ContextInitOptionsPaths, RuntimeEnvironment} from "./model"; import {ElectronContextLocations} from "src/shared/model/electron"; -import {INITIAL_STORES} from "./constants"; import {RUNTIME_ENV_E2E, RUNTIME_ENV_USER_DATA_DIR} from "src/shared/constants"; export async function initContext(options: ContextInitOptions = {}): Promise { @@ -27,7 +27,8 @@ export async function initContext(options: ContextInitOptions = {}): Promise eval("require")("rolling-rate-limiter"), + webLogger: buildLoggerBundle("[WEB]"), + require: { + "rolling-rate-limiter": () => _require("rolling-rate-limiter"), + }, }, }; diff --git a/src/electron-preload/util.ts b/src/electron-preload/util.ts new file mode 100644 index 000000000..22db21419 --- /dev/null +++ b/src/electron-preload/util.ts @@ -0,0 +1,6 @@ +// tslint:disable-next-line:no-import-zones +import logger from "electron-log"; + +import {curryFunctionMembers} from "src/shared/util"; + +export const buildLoggerBundle = (prefix: string) => curryFunctionMembers(logger, prefix); diff --git a/src/electron-preload/webview/common.ts b/src/electron-preload/webview/common.ts deleted file mode 100644 index 9ed53016e..000000000 --- a/src/electron-preload/webview/common.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {ONE_SECOND_MS} from "src/shared/constants"; - -export const NOTIFICATION_LOGGED_IN_POLLING_INTERVAL = ONE_SECOND_MS; - -export const NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL = ONE_SECOND_MS * 1.5; diff --git a/src/electron-preload/webview/constants.ts b/src/electron-preload/webview/constants.ts new file mode 100644 index 000000000..9debb856f --- /dev/null +++ b/src/electron-preload/webview/constants.ts @@ -0,0 +1,12 @@ +import {AccountType} from "src/shared/model/account"; +import {buildLoggerBundle} from "src/electron-preload/util"; +import {ONE_SECOND_MS} from "src/shared/constants"; + +export const NOTIFICATION_LOGGED_IN_POLLING_INTERVAL = ONE_SECOND_MS; + +export const NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL = ONE_SECOND_MS * 1.5; + +export const WEBVIEW_LOGGERS: Record> = { + tutanota: buildLoggerBundle("[WEBVIEW:tutanota]"), + protonmail: buildLoggerBundle("[WEBVIEW:protonmail]"), +}; diff --git a/src/electron-preload/webview/protonmail/index.ts b/src/electron-preload/webview/protonmail/index.ts index c7022edd0..dfda2b393 100644 --- a/src/electron-preload/webview/protonmail/index.ts +++ b/src/electron-preload/webview/protonmail/index.ts @@ -1,13 +1,19 @@ import {authenticator} from "otplib"; -import {distinctUntilChanged, map} from "rxjs/operators"; -import {EMPTY, from, interval, merge, Observable, Subscriber, throwError} from "rxjs"; - -import {AccountNotificationType, WebAccountProtonmail} from "src/shared/model/account"; -import {getLocationHref, submitTotpToken, typeInputValue, waitElements} from "src/electron-preload/webview/util"; -import {NOTIFICATION_LOGGED_IN_POLLING_INTERVAL, NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL} from "src/electron-preload/webview/common"; +import {distinctUntilChanged, map, shareReplay, tap} from "rxjs/operators"; +import {EMPTY, from, interval, merge, Observable, Subscriber} from "rxjs"; + +import { + NOTIFICATION_LOGGED_IN_POLLING_INTERVAL, + NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL, + WEBVIEW_LOGGERS, +} from "src/electron-preload/webview/constants"; +import {AccountNotificationType} from "src/shared/model/account"; +import {fillInputValue, getLocationHref, submitTotpToken, waitElements} from "src/electron-preload/webview/util"; import {PROTONMAIL_IPC_WEBVIEW_API, ProtonmailApi} from "src/shared/api/webview/protonmail"; +import {curryFunctionMembers} from "src/shared/util"; const WINDOW = window as any; +const logger = curryFunctionMembers(WEBVIEW_LOGGERS.protonmail, "[index]"); const twoFactorCodeElementId = "twoFactorCode"; delete WINDOW.Notification; @@ -15,173 +21,199 @@ delete WINDOW.Notification; const endpoints: ProtonmailApi = { ping: () => EMPTY, - fillLogin: ({login}) => from((async () => { + fillLogin: ({login, zoneName}) => from((async () => { + const _logPrefix = ["fillLogin()", zoneName]; + logger.info(..._logPrefix); + const elements = await waitElements({ username: () => document.getElementById("username") as HTMLInputElement, }); - const username = elements.username(); + logger.verbose(..._logPrefix, `elements resolved`); + + await fillInputValue(elements.username, login); + logger.verbose(..._logPrefix, `input values filled`); - await typeInputValue(username, login); - username.readOnly = true; + elements.username.readOnly = true; return EMPTY.toPromise(); })()), - login: ({login, password}) => from((async () => { - await endpoints.fillLogin({login}).toPromise(); + login: ({login, password, zoneName}) => from((async () => { + const _logPrefix = ["login()", zoneName]; + logger.info(..._logPrefix); + + await endpoints.fillLogin({login, zoneName}).toPromise(); + logger.verbose(..._logPrefix, `fillLogin() executed`); const elements = await waitElements({ password: () => document.getElementById("password") as HTMLInputElement, submit: () => document.getElementById("login_btn") as HTMLElement, }); + logger.verbose(..._logPrefix, `elements resolved`); - if (elements.password().value) { - throw new Error("Password is not supposed to be filled already on this stage"); + if (elements.password.value) { + throw new Error(`Password is not supposed to be filled already on "login" stage`); } - await typeInputValue(elements.password(), password); - elements.submit().click(); + await fillInputValue(elements.password, password); + logger.verbose(..._logPrefix, `input values filled`); + + elements.submit.click(); + logger.verbose(..._logPrefix, `clicked`); return EMPTY.toPromise(); })()), - login2fa: ({secret}) => from((async () => { + login2fa: ({secret, zoneName}) => from((async () => { + const _logPrefix = ["login2fa()", zoneName]; + logger.info(..._logPrefix); + const elements = await waitElements({ input: () => document.getElementById(twoFactorCodeElementId) as HTMLInputElement, button: () => document.getElementById("login_btn_2fa") as HTMLElement, }); + logger.verbose(..._logPrefix, `elements resolved`); return await submitTotpToken( - elements.input(), - elements.button(), + elements.input, + elements.button, () => authenticator.generate(secret), + logger, + _logPrefix, ); })()), - notification: ({entryUrl}) => { - try { - const observables = [ - interval(NOTIFICATION_LOGGED_IN_POLLING_INTERVAL).pipe( - map(() => isLoggedIn()), - distinctUntilChanged(), - map((loggedIn) => ({loggedIn})), - ), - - // TODO listen for location.href change instead of starting polling interval - interval(NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL).pipe( - map(() => { - const url = getLocationHref(); - const pageType: (Pick, "pageType">)["pageType"] = { - url, - type: "undefined", - }; - - if (!isLoggedIn()) { - switch (url) { - case `${entryUrl}/login`: { - const twoFactorCode = document.getElementById(twoFactorCodeElementId); - const twoFactorCodeVisible = twoFactorCode && twoFactorCode.offsetParent; - - if (twoFactorCodeVisible) { - pageType.type = "login2fa"; - } else { - pageType.type = "login"; - } - - break; - } - case `${entryUrl}/login/unlock`: { - pageType.type = "unlock"; - break; + unlock: ({mailPassword, zoneName}) => from((async () => { + logger.info("unlock()", zoneName); + const elements = await waitElements({ + mailboxPassword: () => document.getElementById("password") as HTMLInputElement, + submit: () => document.getElementById("unlock_btn") as HTMLElement, + }); + + await fillInputValue(elements.mailboxPassword, mailPassword); + elements.submit.click(); + + return EMPTY.toPromise(); + })()), + + notification: ({entryUrl, zoneName}) => { + const _logPrefix = ["notification()", zoneName]; + logger.info(..._logPrefix); + + type LoggedInOutput = Required>; + type PageTypeOutput = Required>; + type UnreadOutput = Required>; + + const observables: [ + Observable, + Observable, + Observable + ] = [ + interval(NOTIFICATION_LOGGED_IN_POLLING_INTERVAL).pipe( + map(() => isLoggedIn()), + distinctUntilChanged(), + map((loggedIn) => ({loggedIn})), + ), + + // TODO listen for location.href change instead of starting polling interval + interval(NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL).pipe( + map(() => { + const url = getLocationHref(); + const pageType: PageTypeOutput["pageType"] = {url, type: "unknown"}; + + if (!isLoggedIn()) { + switch (url) { + case `${entryUrl}/login`: { + const twoFactorCode = document.getElementById(twoFactorCodeElementId); + const twoFactorCodeVisible = twoFactorCode && twoFactorCode.offsetParent; + + if (twoFactorCodeVisible) { + pageType.type = "login2fa"; + } else { + pageType.type = "login"; } + + break; + } + case `${entryUrl}/login/unlock`: { + pageType.type = "unlock"; + break; } } + } + + return {pageType}; + }), + distinctUntilChanged(({pageType: prev}, {pageType: curr}) => curr.type === prev.type), + tap((value) => logger.verbose(..._logPrefix, JSON.stringify(value))), + ), + + (() => { + const responseListeners = [ + { + re: new RegExp(`${entryUrl}/api/messages/count`), + handler: ({Counts}: { Counts?: Array<{ LabelID: string; Unread: number; }> }) => { + if (!Counts) { + return; + } - return {pageType}; - }), - distinctUntilChanged(({pageType: prev}, {pageType: curr}) => prev.type === curr.type), - ), - - // unread - (() => { - const responseListeners = [ - { - re: new RegExp(`${entryUrl}/api/messages/count`), - handler: ({Counts}: { Counts?: Array<{ LabelID: string; Unread: number; }> }) => { - if (!Counts) { - return; - } - - return Counts - .filter(({LabelID}) => LabelID === "0") - .reduce((accumulator, item) => accumulator + item.Unread, 0); - }, + return Counts + .filter(({LabelID}) => LabelID === "0") + .reduce((accumulator, item) => accumulator + item.Unread, 0); }, - { - re: new RegExp(`${entryUrl}/api/events/.*==`), - handler: ({MessageCounts}: { MessageCounts?: Array<{ LabelID: string; Unread: number; }> }) => { - if (!MessageCounts) { - return; - } + }, + { + re: new RegExp(`${entryUrl}/api/events/.*==`), + handler: ({MessageCounts}: { MessageCounts?: Array<{ LabelID: string; Unread: number; }> }) => { + if (!MessageCounts) { + return; + } - return MessageCounts - .filter(({LabelID}) => LabelID === "0") - .reduce((accumulator, item) => accumulator + item.Unread, 0); - }, + return MessageCounts + .filter(({LabelID}) => LabelID === "0") + .reduce((accumulator, item) => accumulator + item.Unread, 0); }, - ]; - - return Observable.create((observer: Subscriber, "unread">>) => { - XMLHttpRequest.prototype.send = ((original) => { - return function(this: XMLHttpRequest) { - this.addEventListener("load", function(this: XMLHttpRequest) { - responseListeners - .filter(({re}) => re.test(this.responseURL)) - .forEach(({handler}) => { - const responseData = JSON.parse(this.responseText); - const value = (handler as any)(responseData); - - if (typeof value === "number") { - observer.next({unread: value}); - } - }); - }, false); - - return original.apply(this, arguments); - } as any; - })(XMLHttpRequest.prototype.send); - }); - })(), - ]; - - return merge(...observables); - } catch (error) { - return throwError(error); - } + }, + ]; + + return Observable.create((observer: Subscriber) => { + XMLHttpRequest.prototype.send = ((original) => { + return function(this: XMLHttpRequest) { + this.addEventListener("load", function(this: XMLHttpRequest) { + responseListeners + .filter(({re}) => re.test(this.responseURL)) + .forEach(({handler}) => { + const responseData = JSON.parse(this.responseText); + const value = (handler as any)(responseData); + + if (typeof value === "number") { + observer.next({unread: value}); + } + }); + }, false); + + return original.apply(this, arguments); + } as any; + })(XMLHttpRequest.prototype.send); + }).pipe( + distinctUntilChanged(({unread: prev}, {unread: curr}) => curr === prev), + ); + })(), + ]; + + return merge(...observables).pipe( + shareReplay(1), + ); }, - - unlock: ({mailPassword}) => from((async () => { - const elements = await waitElements({ - password: () => document.getElementById("password") as HTMLInputElement, - submit: () => document.getElementById("unlock_btn") as HTMLElement, - }); - const $formController: any = WINDOW.$(elements.password().form).data("$formController"); - - $formController["mailbox-password"].$setViewValue(mailPassword); - $formController["mailbox-password"].$render(); - - elements.submit().click(); - - return EMPTY.toPromise(); - })()), }; PROTONMAIL_IPC_WEBVIEW_API.registerApi(endpoints); +logger.verbose(`api registered, url: ${getLocationHref()}`); function isLoggedIn(): boolean { - const html = WINDOW.angular && WINDOW.angular.element("html"); - const $injector = html.data("$injector"); - const authentication = $injector.get("authentication"); + const htmlElement = WINDOW.angular && typeof WINDOW.angular.element === "function" && WINDOW.angular.element("html"); + const $injector = htmlElement && typeof htmlElement.data === "function" && htmlElement.data("$injector"); + const authentication = $injector && $injector.get("authentication"); return authentication && authentication.isLoggedIn(); } diff --git a/src/electron-preload/webview/stub/index.ts b/src/electron-preload/webview/stub/index.ts deleted file mode 100644 index a734edb08..000000000 --- a/src/electron-preload/webview/stub/index.ts +++ /dev/null @@ -1 +0,0 @@ -// NOOP diff --git a/src/electron-preload/webview/stub/tsconfig.json b/src/electron-preload/webview/stub/tsconfig.json deleted file mode 100644 index 6c51e2627..000000000 --- a/src/electron-preload/webview/stub/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../../../../tsconfig.json", - "include": [ - "./**/*" - ] -} diff --git a/src/electron-preload/webview/tutanota/index.ts b/src/electron-preload/webview/tutanota/index.ts index 92d2d76bc..3a67e0dc3 100644 --- a/src/electron-preload/webview/tutanota/index.ts +++ b/src/electron-preload/webview/tutanota/index.ts @@ -1,20 +1,24 @@ -import logger from "electron-log"; import {authenticator} from "otplib"; -import {distinctUntilChanged, map, switchMap} from "rxjs/operators"; +import {distinctUntilChanged, map, shareReplay, concatMap, tap} from "rxjs/operators"; import {EMPTY, from, interval, merge, Observable, Subscriber} from "rxjs"; -import {AccountNotificationType, WebAccountTutanota} from "src/shared/model/account"; +import { + NOTIFICATION_LOGGED_IN_POLLING_INTERVAL, + NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL, + WEBVIEW_LOGGERS, +} from "src/electron-preload/webview/constants"; +import {AccountNotificationType} from "src/shared/model/account"; import {fetchEntitiesRange} from "src/electron-preload/webview/tutanota/lib/rest"; import {fetchMessages, fetchUserFoldersWithSubFolders} from "src/electron-preload/webview/tutanota/lib/fetcher"; -import {getLocationHref, submitTotpToken, typeInputValue, waitElements} from "src/electron-preload/webview/util"; +import {fillInputValue, getLocationHref, submitTotpToken, waitElements} from "src/electron-preload/webview/util"; +import {curryFunctionMembers, MailFolderTypeService} from "src/shared/util"; import {MailTypeRef, User} from "src/electron-preload/webview/tutanota/lib/rest/model"; -import {NOTIFICATION_LOGGED_IN_POLLING_INTERVAL, NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL} from "src/electron-preload/webview/common"; import {ONE_SECOND_MS} from "src/shared/constants"; import {resolveWebClientApi, WebClientApi} from "src/electron-preload/webview/tutanota/lib/tutanota-api"; import {TUTANOTA_IPC_WEBVIEW_API, TutanotaApi} from "src/shared/api/webview/tutanota"; -import {MailFolderTypeService} from "src/shared/util"; const WINDOW = window as any; +const logger = curryFunctionMembers(WEBVIEW_LOGGERS.tutanota, "[index]"); resolveWebClientApi() .then((webClientApi) => { @@ -32,84 +36,101 @@ function bootstrapApi(webClientApi: WebClientApi) { const endpoints: TutanotaApi = { ping: () => EMPTY, - fetchMessages: (input) => { + fetchMessages: ({login, newestStoredTimestamp, type}) => { const controller = getUserController(); if (controller) { - return fetchMessages({ - ...input, - user: controller.user, - }); + return fetchMessages({login, newestStoredTimestamp, type, user: controller.user}); } return EMPTY; }, - fillLogin: ({login}) => { + fillLogin: ({login, zoneName}) => from((async () => { + const _logPrefix = ["fillLogin()", zoneName]; + logger.info(..._logPrefix); + const cancelEvenHandler = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); }; - const usernameSelector = "form [type=email]"; - const waitElementsConfig = { - username: () => document.querySelector(usernameSelector) as HTMLInputElement, + const elements = await waitElements({ + username: () => document.querySelector("form [type=email]") as HTMLInputElement, storePasswordCheckbox: () => document.querySelector("form .items-center [type=checkbox]") as HTMLInputElement, storePasswordCheckboxBlock: () => document.querySelector("form .checkbox.pt.click") as HTMLInputElement, - }; + }); + logger.verbose(..._logPrefix, `elements resolved`); - return from((async () => { - const elements = await waitElements(waitElementsConfig); - const username = elements.username(); - const storePasswordCheckbox = elements.storePasswordCheckbox(); - const storePasswordCheckboxBlock = elements.storePasswordCheckboxBlock(); + await fillInputValue(elements.username, login); + elements.username.readOnly = true; + logger.verbose(..._logPrefix, `input values filled`); - await typeInputValue(username, login); - username.readOnly = true; + elements.storePasswordCheckbox.checked = false; + elements.storePasswordCheckbox.disabled = true; + elements.storePasswordCheckboxBlock.removeEventListener("click", cancelEvenHandler); + elements.storePasswordCheckboxBlock.addEventListener("click", cancelEvenHandler, true); + logger.verbose(..._logPrefix, `"store" checkbox disabled`); - storePasswordCheckbox.checked = false; - storePasswordCheckbox.disabled = true; - storePasswordCheckboxBlock.removeEventListener("click", cancelEvenHandler); - storePasswordCheckboxBlock.addEventListener("click", cancelEvenHandler, true); + return EMPTY.toPromise(); + })()), - return EMPTY.toPromise(); - })()); - }, + login: ({password: passwordValue, login, zoneName}) => from((async () => { + const _logPrefix = ["login()", zoneName]; + logger.info(..._logPrefix); + + await endpoints.fillLogin({login, zoneName}).toPromise(); + logger.verbose(..._logPrefix, `fillLogin() executed`); - login: ({password: passwordValue, login}) => { - const waitElementsConfig = { + const elements = await waitElements({ password: () => document.querySelector("form [type=password]") as HTMLInputElement, submit: () => document.querySelector("form button") as HTMLElement, - }; + }); + logger.verbose(..._logPrefix, `elements resolved`); - return from((async () => { - await endpoints.fillLogin({login}).toPromise(); + if (elements.password.value) { + throw new Error(`Password is not supposed to be filled already on "login" stage`); + } - const elements = await waitElements(waitElementsConfig); + await fillInputValue(elements.password, passwordValue); + logger.verbose(..._logPrefix, `input values filled`); - if (elements.password().value) { - throw new Error("Password is not supposed to be filled already on this stage"); - } + elements.submit.click(); + logger.verbose(..._logPrefix, `clicked`); - await typeInputValue(elements.password(), passwordValue); - elements.submit().click(); + return EMPTY.toPromise(); + })()), - return EMPTY.toPromise(); - })()); - }, + login2fa: ({secret, zoneName}) => from((async () => { + const _logPrefix = ["login2fa()", zoneName]; + logger.info(..._logPrefix); - login2fa: ({secret}) => from((async () => { const elements = await waitElements(login2FaWaitElementsConfig); + logger.verbose(..._logPrefix, `elements resolved`); + const spacesLessSecret = secret.replace(/\s/g, ""); return await submitTotpToken( - elements.input(), - elements.button(), + elements.input, + elements.button, () => authenticator.generate(spacesLessSecret), + logger, + _logPrefix, ); })()), - notification: ({entryUrl}) => { - const observables = [ + notification: ({entryUrl, zoneName}) => { + const _logPrefix = ["notification()", zoneName]; + logger.info(..._logPrefix); + + type LoggedInOutput = Required>; + type PageTypeOutput = Required>; + type UnreadOutput = Required>; + + const observables: [ + Observable, + Observable, + Observable + ] = [ interval(NOTIFICATION_LOGGED_IN_POLLING_INTERVAL).pipe( map(() => isLoggedIn()), distinctUntilChanged(), @@ -118,26 +139,23 @@ function bootstrapApi(webClientApi: WebClientApi) { // TODO listen for location.href change instead of starting polling interval interval(NOTIFICATION_PAGE_TYPE_POLLING_INTERVAL).pipe( - switchMap(() => from((async () => { + concatMap(() => from((async () => { const url = getLocationHref(); - const pageType: (Pick, "pageType">)["pageType"] = { - url, - type: "undefined", - }; + const pageType: PageTypeOutput["pageType"] = {url, type: "unknown"}; const loginUrlDetected = (url === `${entryUrl}/login` || url.startsWith(`${entryUrl}/login?`)); if (loginUrlDetected && !isLoggedIn()) { let twoFactorElements; try { - twoFactorElements = await waitElements(login2FaWaitElementsConfig, {attemptsLimit: 1}); + twoFactorElements = await waitElements(login2FaWaitElementsConfig, {iterationsLimit: 1}); } catch (e) { // NOOP } const twoFactorCodeVisible = twoFactorElements - && twoFactorElements.input().offsetParent - && twoFactorElements.button().offsetParent; + && twoFactorElements.input.offsetParent + && twoFactorElements.button.offsetParent; if (twoFactorCodeVisible) { pageType.type = "login2fa"; @@ -148,11 +166,12 @@ function bootstrapApi(webClientApi: WebClientApi) { return {pageType}; })())), - distinctUntilChanged(({pageType: prev}, {pageType: curr}) => prev.type === curr.type), + distinctUntilChanged(({pageType: prev}, {pageType: curr}) => curr.type === prev.type), + tap((value) => logger.verbose(..._logPrefix, JSON.stringify(value))), ), // TODO listen for "unread" change instead of starting polling interval - Observable.create((observer: Subscriber<{ unread: number }>) => { + Observable.create((observer: Subscriber) => { const notifyUnreadValue = async () => { const controller = getUserController(); @@ -184,28 +203,33 @@ function bootstrapApi(webClientApi: WebClientApi) { setInterval(notifyUnreadValue, ONE_SECOND_MS * 60); setTimeout(notifyUnreadValue, ONE_SECOND_MS * 15); - }), + }).pipe( + distinctUntilChanged(({unread: prev}, {unread: curr}) => curr === prev), + ), ]; - return merge(...observables); + return merge(...observables).pipe( + shareReplay(1), + ); }, }; TUTANOTA_IPC_WEBVIEW_API.registerApi(endpoints); -} - -function getUserController(): { accessToken: string, user: User } | null { - return WINDOW.tutao - && WINDOW.tutao.logins - && typeof WINDOW.tutao.logins.getUserController === "function" - && WINDOW.tutao.logins.getUserController() ? WINDOW.tutao.logins.getUserController() - : null; -} - -function isLoggedIn(): boolean { - const controller = getUserController(); - return !!(controller - && controller.accessToken - && controller.accessToken.length - ); + logger.verbose(`api registered, url: ${getLocationHref()}`); + + function getUserController(): { accessToken: string, user: User } | null { + return WINDOW.tutao + && WINDOW.tutao.logins + && typeof WINDOW.tutao.logins.getUserController === "function" + && WINDOW.tutao.logins.getUserController() ? WINDOW.tutao.logins.getUserController() + : null; + } + + function isLoggedIn(): boolean { + const controller = getUserController(); + return !!(controller + && controller.accessToken + && controller.accessToken.length + ); + } } diff --git a/src/electron-preload/webview/util.ts b/src/electron-preload/webview/util.ts index 4868e3136..a17beede9 100644 --- a/src/electron-preload/webview/util.ts +++ b/src/electron-preload/webview/util.ts @@ -1,57 +1,60 @@ import {EMPTY} from "rxjs"; import {Keyboard} from "keysim"; +import {asyncDelay} from "src/shared/util"; +import {buildLoggerBundle} from "src/electron-preload/util"; import {ONE_SECOND_MS} from "src/shared/constants"; -export const waitElements = E }>( - queries: T, - opts: { timeoutMs?: number, attemptsLimit?: number } = {}, -): Promise => new Promise((resolve, reject) => { - const timeoutMs = opts.timeoutMs || ONE_SECOND_MS * 10; - const attemptsLimit = opts.attemptsLimit || 0; // 0 - unlimited - const delayMinMs = 300; - +export const waitElements = E>>, + K extends keyof Q, + R extends { [key in K]: ReturnType }>( + query: Q, + opts: { timeoutMs?: number, iterationsLimit?: number } = {}, +): Promise> => new Promise((resolve, reject) => { + const OPTS = { + timeoutMs: opts.timeoutMs || ONE_SECOND_MS * 10, + iterationsLimit: opts.iterationsLimit || 0, // 0 - unlimited + delayMinMs: 300, + }; const startTime = Number(new Date()); - const delayMs = timeoutMs / 50; - const keys = Object.keys(queries) as [keyof T]; - const result = {} as T; - - let attempt = 0; - - iteration(); + const delayMs = OPTS.timeoutMs / 50; + const queryKeys: K[] = Object.keys(query) as K[]; + const resolvedElements: Partial = {}; + let iteration = 0; - function iteration() { - attempt++; + scanElements(); - keys.reduce((store, key) => { - if (!(key in store)) { - const el = queries[key](); + function scanElements() { + iteration++; - if (el) { - store[key] = () => el; - } + queryKeys.forEach((key) => { + if (key in resolvedElements) { + return; } + const element = query[key](); + if (element) { + resolvedElements[key] = element as any; + } + }); - return store; - }, result); - - if (Object.keys(result).length === keys.length) { - return resolve(result); + if (Object.keys(resolvedElements).length === queryKeys.length) { + return resolve(resolvedElements as R); } - if (attemptsLimit && (attempt >= attemptsLimit)) { + if (OPTS.iterationsLimit && (iteration >= OPTS.iterationsLimit)) { return reject(new Error( - `Failed to locate some DOM elements: [${Object.keys(queries).join(", ")}] having made ${attempt} attempts`, + `Failed to resolve some DOM elements from the list [${queryKeys.join(", ")}] having "${iteration}" iterations performed`, )); } - if (Number(new Date()) - startTime > timeoutMs) { + if (Number(new Date()) - startTime > OPTS.timeoutMs) { return reject(new Error( - `Failed to locate some DOM elements: [${Object.keys(queries).join(", ")}] within ${timeoutMs}ms`, + `Failed to resolve some DOM elements from the list [${queryKeys.join(", ")}] within "${OPTS.timeoutMs}" milliseconds`, )); } - setTimeout(iteration, Math.max(delayMinMs, delayMs)); + setTimeout(scanElements, Math.max(OPTS.delayMinMs, delayMs)); } }); @@ -59,7 +62,7 @@ export function getLocationHref(): string { return (window as any).location.href; } -export async function typeInputValue(input: HTMLInputElement, value: string) { +export async function fillInputValue(input: HTMLInputElement, value: string) { input.value = value; Keyboard.US_ENGLISH.dispatchEventsForInput(value, input); } @@ -68,8 +71,15 @@ export async function submitTotpToken( input: HTMLInputElement, button: HTMLElement, tokenResolver: () => string, - {submitTimeoutMs}: { submitTimeoutMs: number } = {submitTimeoutMs: 4000}, + logger: ReturnType, + _logPrefixParam: string[], ): Promise { + const _logPrefix = ["submitTotpToken()", ..._logPrefixParam]; + logger.info(..._logPrefix); + + const submitTimeoutMs = ONE_SECOND_MS * 4; + const newTokenDelayMs = ONE_SECOND_MS * 2; + if (input.value) { throw new Error("2FA TOTP token is not supposed to be pre-filled on this stage"); } @@ -79,27 +89,33 @@ export async function submitTotpToken( try { await submit(); } catch (e) { - if (e.message === errorMessage) { - // second attempt as token might become expired right before submitting - await new Promise((resolve) => setTimeout(resolve, submitTimeoutMs)); - await submit(); + if (e.message !== errorMessage) { + throw e; } - throw e; + + logger.verbose(..._logPrefix, `submit 1 - fail: ${e.message}`); + // second attempt as token might become expired right before submitting + await asyncDelay(newTokenDelayMs, submit); } return EMPTY.toPromise(); async function submit() { + logger.verbose(..._logPrefix, "submit - start"); const urlBeforeSubmit = getLocationHref(); - await typeInputValue(input, tokenResolver()); + await fillInputValue(input, tokenResolver()); + logger.verbose(..._logPrefix, "input filled"); button.click(); + logger.verbose(..._logPrefix, "clicked"); - await new Promise((resolve) => setTimeout(resolve, submitTimeoutMs)); + await asyncDelay(submitTimeoutMs); if (getLocationHref() === urlBeforeSubmit) { throw new Error(errorMessage); } + + logger.verbose(..._logPrefix, "submit - success"); } } diff --git a/src/shared/api/common.ts b/src/shared/api/common.ts new file mode 100644 index 000000000..1a5f1614b --- /dev/null +++ b/src/shared/api/common.ts @@ -0,0 +1,4 @@ +// TODO consider wiring-up this parameter to all API calls automatically, hiding a complexity (encapsulating) +export interface ZoneApiParameter { + zoneName: string; +} diff --git a/src/shared/api/webview/common.ts b/src/shared/api/webview/common.ts index fb345ecd6..b968816fd 100644 --- a/src/shared/api/webview/common.ts +++ b/src/shared/api/webview/common.ts @@ -1,13 +1,14 @@ -import {ApiMethod, ApiMethodNoArgument} from "electron-rpc-api"; +import {ApiMethod} from "electron-rpc-api"; import {APP_NAME} from "src/shared/constants"; import {LoginFieldContainer, PasswordFieldContainer} from "src/shared/model/container"; +import {ZoneApiParameter} from "src/shared/api/common"; export const channel = `${APP_NAME}:webview-api`; export interface CommonApi { - ping: ApiMethodNoArgument; - fillLogin: ApiMethod; - login: ApiMethod; - login2fa: ApiMethod<{ secret: string }, never>; + ping: ApiMethod; + fillLogin: ApiMethod; + login: ApiMethod; + login2fa: ApiMethod<{ secret: string } & ZoneApiParameter, never>; } diff --git a/src/shared/api/webview/protonmail.ts b/src/shared/api/webview/protonmail.ts index 13587e286..29f8dc8d3 100644 --- a/src/shared/api/webview/protonmail.ts +++ b/src/shared/api/webview/protonmail.ts @@ -4,10 +4,11 @@ import {AccountNotifications, WebAccountProtonmail} from "src/shared/model/accou import {channel} from "./common"; import {CommonApi} from "src/shared/api/webview/common"; import {MailPasswordFieldContainer} from "src/shared/model/container"; +import {ZoneApiParameter} from "src/shared/api/common"; export interface ProtonmailApi extends CommonApi { - notification: ApiMethod<{ entryUrl: string }, AccountNotifications>; - unlock: ApiMethod; + notification: ApiMethod<{ entryUrl: string } & ZoneApiParameter, Partial>>; + unlock: ApiMethod; } export const PROTONMAIL_IPC_WEBVIEW_API = new WebViewApiService({channel}); diff --git a/src/shared/api/webview/tutanota.ts b/src/shared/api/webview/tutanota.ts index 42ae8e585..cd8e1c69a 100644 --- a/src/shared/api/webview/tutanota.ts +++ b/src/shared/api/webview/tutanota.ts @@ -5,6 +5,7 @@ import {channel} from "./common"; import {CommonApi} from "src/shared/api/webview/common"; import {Mail} from "src/shared/model/database"; import {Omit, Timestamp} from "src/shared/types"; +import {ZoneApiParameter} from "src/shared/api/common"; export interface TutanotaApiFetchMessagesInput { type: AccountType; @@ -17,8 +18,8 @@ export interface TutanotaApiFetchMessagesOutput { } export interface TutanotaApi extends CommonApi { - notification: ApiMethod<{ entryUrl: string }, AccountNotifications>; - fetchMessages: ApiMethod; + notification: ApiMethod<{ entryUrl: string } & ZoneApiParameter, Partial>>; + fetchMessages: ApiMethod; } export const TUTANOTA_IPC_WEBVIEW_API = new WebViewApiService({channel}); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 90d0ab432..e5f4fd0ce 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,4 +1,7 @@ +import {LogLevel} from "electron-log"; + import {AccountType} from "src/shared/model/account"; +import {EntryUrlItem} from "./types"; // tslint:disable-next-line:no-var-requires no-import-zones const {name: APP_NAME, version: APP_VERSION} = require("package.json"); @@ -10,23 +13,11 @@ export { // user data dir, defaults to app.getPath("userData") export const RUNTIME_ENV_USER_DATA_DIR = `EMAIL_SECURELY_APP_USER_DATA_DIR`; + // boolean export const RUNTIME_ENV_E2E = `EMAIL_SECURELY_APP_E2E`; -// protonmail account to login during e2e tests running -export const RUNTIME_ENV_E2E_PROTONMAIL_LOGIN = `EMAIL_SECURELY_APP_E2E_PROTONMAIL_LOGIN`; -export const RUNTIME_ENV_E2E_PROTONMAIL_PASSWORD = `EMAIL_SECURELY_APP_E2E_PROTONMAIL_PASSWORD`; -export const RUNTIME_ENV_E2E_PROTONMAIL_2FA_CODE = `EMAIL_SECURELY_APP_E2E_PROTONMAIL_2FA_CODE`; -export const RUNTIME_ENV_E2E_PROTONMAIL_UNREAD_MIN = `EMAIL_SECURELY_APP_E2E_PROTONMAIL_UNREAD_MIN`; -// tutanota account to login during e2e tests running -export const RUNTIME_ENV_E2E_TUTANOTA_LOGIN = `EMAIL_SECURELY_APP_E2E_TUTANOTA_LOGIN`; -export const RUNTIME_ENV_E2E_TUTANOTA_PASSWORD = `EMAIL_SECURELY_APP_E2E_TUTANOTA_PASSWORD`; -export const RUNTIME_ENV_E2E_TUTANOTA_2FA_CODE = `EMAIL_SECURELY_APP_E2E_TUTANOTA_2FA_CODE`; -export const RUNTIME_ENV_E2E_TUTANOTA_UNREAD_MIN = `EMAIL_SECURELY_APP_E2E_TUTANOTA_UNREAD_MIN`; - -export interface EntryUrlItem { - value: string; - title: string; -} + +export const ONE_SECOND_MS = 1000; export const ACCOUNTS_CONFIG: Record> = { protonmail: { @@ -48,4 +39,11 @@ export const WEBVIEW_SRC_WHITELIST: string[] = Object .reduce((list, [accountType, {entryUrl}]) => list.concat(entryUrl), [] as EntryUrlItem[]) .map(({value}) => value); -export const ONE_SECOND_MS = 1000; +export const LOG_LEVELS: LogLevel[] = Object.keys(((stub: Record) => stub)({ + error: null, + warn: null, + info: null, + verbose: null, + debug: null, + silly: null, +})) as LogLevel[]; diff --git a/src/shared/model/account.ts b/src/shared/model/account.ts index 6cf8d02d0..e5549f899 100644 --- a/src/shared/model/account.ts +++ b/src/shared/model/account.ts @@ -30,13 +30,13 @@ interface GenericWebAccount< export type WebAccountProtonmail = GenericWebAccount< "protonmail", "password" | "twoFactorCode" | "mailPassword", - "undefined" | "login" | "login2fa" | "unlock" + "unknown" | "login" | "login2fa" | "unlock" >; export type WebAccountTutanota = GenericWebAccount< "tutanota", "password" | "twoFactorCode", - "undefined" | "login" | "login2fa" + "unknown" | "login" | "login2fa" >; export type WebAccount = WebAccountProtonmail | WebAccountTutanota; diff --git a/src/shared/model/electron.ts b/src/shared/model/electron.ts index 131c30ef6..3d0317a7a 100644 --- a/src/shared/model/electron.ts +++ b/src/shared/model/electron.ts @@ -1,13 +1,15 @@ +import logger from "electron-log"; +import {IpcRenderer} from "electron"; + import {AccountType} from "src/shared/model/account"; +import {Omit} from "src/shared/types"; export interface ElectronExposure { - ipcRenderer: { - on(channel: string, listener: (event: string, response: any) => void): any; - removeListener(channel: string, listener: (event: string, response: any) => void): any; - send(channel: string, ...args: any[]): void; - sendToHost(channel: string, ...args: any[]): void; + ipcRendererTransport: Pick; + webLogger: Omit; + require: { + "rolling-rate-limiter": () => (...args: any[]) => (key: string) => number, }; - requireNodeRollingRateLimiter: () => (...args: any[]) => (key: string) => number; } export interface ElectronWindow { @@ -25,6 +27,6 @@ export interface ElectronContextLocations { readonly preload: { browserWindow: string; browserWindowE2E: string; - webView: Record & { stub: string }; + webView: Record; }; } diff --git a/src/shared/model/error.ts b/src/shared/model/error.ts index ae70c7400..a280c0b5b 100644 --- a/src/shared/model/error.ts +++ b/src/shared/model/error.ts @@ -1,5 +1,6 @@ export enum StatusCode { NotFoundAccount = 0, + InvalidArgument = 1, } export class StatusCodeError extends Error { diff --git a/src/shared/model/options.ts b/src/shared/model/options.ts index 657629262..33105002c 100644 --- a/src/shared/model/options.ts +++ b/src/shared/model/options.ts @@ -1,35 +1,24 @@ import {EncryptionPresets} from "fs-json-store-encryption-adapter/encryption"; import {KeyDerivationPresets} from "fs-json-store-encryption-adapter/key-derivation"; +import {LogLevel} from "electron-log"; import {Model as StoreModel} from "fs-json-store"; import {Options as EncryptionAdapterOptions} from "fs-json-store-encryption-adapter"; import {AccountConfig} from "src/shared/model/account"; import {KeePassClientConfFieldContainer, KeePassRefFieldContainer} from "src/shared/model/container"; -export const KEY_DERIVATION_PRESETS: Record = { - "node.pbkdf2 (interactive)": {type: "pbkdf2", preset: "mode:interactive|digest:sha256"}, - "node.pbkdf2 (moderate)": {type: "pbkdf2", preset: "mode:moderate|digest:sha256"}, - "node.pbkdf2 (sensitive)": {type: "pbkdf2", preset: "mode:sensitive|digest:sha256"}, - "sodium.crypto_pwhash (interactive)": {type: "sodium.crypto_pwhash", preset: "mode:interactive|algorithm:default"}, - "sodium.crypto_pwhash (moderate)": {type: "sodium.crypto_pwhash", preset: "mode:moderate|algorithm:default"}, - "sodium.crypto_pwhash (sensitive)": {type: "sodium.crypto_pwhash", preset: "mode:sensitive|algorithm:default"}, -}; - -export const ENCRYPTION_DERIVATION_PRESETS: Record = { - "node.crypto (aes-256-cbc)": {type: "crypto", preset: "algorithm:aes-256-cbc"}, - "sodium.crypto_secretbox_easy (default)": {type: "sodium.crypto_secretbox_easy", preset: "algorithm:default"}, -}; - -export interface BaseConfig { - closeToTray?: boolean; - compactLayout?: boolean; - startMinimized?: boolean; - unreadNotifications?: boolean; - checkForUpdatesAndNotify?: boolean; -} +export type BaseConfig = Partial> + & + Pick; export interface Config extends BaseConfig, Partial { encryptionPreset: EncryptionAdapterOptions; + logLevel: LogLevel; window: { maximized?: boolean; bounds: { x?: number; y?: number; width: number; height: number; }; @@ -42,40 +31,16 @@ export interface Settings extends Partial, accounts: AccountConfig[]; } -export const configEncryptionPresetValidator: StoreModel.StoreValidator = async (data) => { - const keyDerivation = data.encryptionPreset.keyDerivation; - const encryption = data.encryptionPreset.encryption; - - const errors = [ - ...(Object.values(KEY_DERIVATION_PRESETS) - .some((value) => value.type === keyDerivation.type && value.preset === keyDerivation.preset) - ? [] - : [`Wrong "config.encryptionPreset.keyDerivation"="${keyDerivation}" value.`]), - ...(Object.values(ENCRYPTION_DERIVATION_PRESETS) - .some((value) => value.type === encryption.type && value.preset === encryption.preset) - ? [] - : [`Wrong "config.encryptionPreset.encryption"="${encryption}" value.`]), - ]; - - return Promise.resolve( - errors.length - ? errors.join(" ") - : null, - ); +export const KEY_DERIVATION_PRESETS: Record = { + "node.pbkdf2 (interactive)": {type: "pbkdf2", preset: "mode:interactive|digest:sha256"}, + "node.pbkdf2 (moderate)": {type: "pbkdf2", preset: "mode:moderate|digest:sha256"}, + "node.pbkdf2 (sensitive)": {type: "pbkdf2", preset: "mode:sensitive|digest:sha256"}, + "sodium.crypto_pwhash (interactive)": {type: "sodium.crypto_pwhash", preset: "mode:interactive|algorithm:default"}, + "sodium.crypto_pwhash (moderate)": {type: "sodium.crypto_pwhash", preset: "mode:moderate|algorithm:default"}, + "sodium.crypto_pwhash (sensitive)": {type: "sodium.crypto_pwhash", preset: "mode:sensitive|algorithm:default"}, }; -export const settingsAccountLoginUniquenessValidator: StoreModel.StoreValidator = async (data) => { - const duplicatedLogins = data.accounts - .map((account) => account.login) - .reduce((duplicated: string[], el, i, logins) => { - if (logins.indexOf(el) !== i && duplicated.indexOf(el) === -1) { - duplicated.push(el); - } - return duplicated; - }, []); - const result = duplicatedLogins.length - ? `Duplicate accounts identified. Duplicated logins: ${duplicatedLogins.join(", ")}.` - : null; - - return Promise.resolve(result); +export const ENCRYPTION_DERIVATION_PRESETS: Record = { + "node.crypto (aes-256-cbc)": {type: "crypto", preset: "algorithm:aes-256-cbc"}, + "sodium.crypto_secretbox_easy (default)": {type: "sodium.crypto_secretbox_easy", preset: "algorithm:default"}, }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 7df9b6d3a..a0bae4095 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -11,3 +11,8 @@ export type UnpackedPromise = T; export type Timestamp = ReturnType; + +export interface EntryUrlItem { + value: string; + title: string; +} diff --git a/src/shared/util.ts b/src/shared/util.ts index 7ec6b83fa..d9ddc73e7 100644 --- a/src/shared/util.ts +++ b/src/shared/util.ts @@ -1,29 +1,31 @@ import {AccountConfig} from "./model/account"; import {BaseConfig, Config} from "./model/options"; + +import {LoginFieldContainer} from "./model/container"; import {MailFolderTypeStringifiedValue, MailFolderTypeTitle, MailFolderTypeValue} from "./model/database"; import {StatusCode, StatusCodeError} from "./model/error"; import {WEBVIEW_SRC_WHITELIST} from "./constants"; export function pickBaseConfigProperties( - {closeToTray, compactLayout, startMinimized, unreadNotifications, checkForUpdatesAndNotify}: Config, -): Record { - return {closeToTray, compactLayout, startMinimized, unreadNotifications, checkForUpdatesAndNotify}; + {closeToTray, compactLayout, startMinimized, unreadNotifications, checkForUpdatesAndNotify, logLevel}: Config, +): BaseConfig { + return {closeToTray, compactLayout, startMinimized, unreadNotifications, checkForUpdatesAndNotify, logLevel}; } export const isWebViewSrcWhitelisted = (src: string) => WEBVIEW_SRC_WHITELIST.some((allowedPrefix) => { return src.startsWith(allowedPrefix); }); -export const findAccountConfigPredicate = (login: string): (account: AccountConfig) => boolean => { - return ({login: existingLogin}) => existingLogin === login; +export const accountPickingPredicate = (criteria: LoginFieldContainer): (account: AccountConfig) => boolean => { + return ({login}) => login === criteria.login; }; -export const findExistingAccountConfig = (accounts: AccountConfig[], login: string): AccountConfig => { - const account = accounts.find(findAccountConfigPredicate(login)); +export const pickAccountStrict = (accounts: AccountConfig[], criteria: LoginFieldContainer): AccountConfig => { + const account = accounts.find(accountPickingPredicate(criteria)); if (!account) { throw new StatusCodeError( - `Account with "${login}" login has not been found`, + `Account with "${criteria.login}" login has not been found`, StatusCode.NotFoundAccount, ); } @@ -31,6 +33,23 @@ export const findExistingAccountConfig = (accounts: AccountConfig[], login: stri return account; }; +export const asyncDelay = async (pauseTimeMs: number, resolveAction?: () => Promise): Promise => { + return await new Promise((resolve) => { + setTimeout(() => typeof resolveAction === "function" ? resolve(resolveAction()) : resolve(), pauseTimeMs); + }); +}; + +export const curryFunctionMembers = (src: T, ...args: any[]): T => { + const dest: T = typeof src === "function" ? src.bind(undefined) : {}; + for (const key of Object.getOwnPropertyNames(src)) { + const srcMember = src[key]; + if (typeof srcMember === "function") { + dest[key] = srcMember.bind(undefined, ...args); + } + } + return dest; +}; + // tslint:disable-next-line:variable-name export const MailFolderTypeService = (() => { const mappedByTitle: Readonly> = { @@ -50,18 +69,10 @@ export const MailFolderTypeService = (() => { }), {} as Record); const values = Object.values(mappedByTitle); - // TODO consider using some module for building custom errors - class InvalidArgumentError extends Error { - constructor(message: string) { - super(message); - Object.setPrototypeOf(this, new.target.prototype); - } - } - function parseValueStrict(value: MailFolderTypeValue | MailFolderTypeStringifiedValue): MailFolderTypeValue { const result = Number(value) as MailFolderTypeValue; if (!values.includes(result)) { - throw new InvalidArgumentError(`Invalid mail folder type value: ${result}`); + throw new StatusCodeError(`Invalid mail folder type value: ${result}`, StatusCode.InvalidArgument); } return result; } @@ -70,7 +81,7 @@ export const MailFolderTypeService = (() => { try { return mappedByValue[parseValueStrict(value)] === title; } catch (e) { - if (e instanceof InvalidArgumentError) { + if (e instanceof StatusCodeError && e.statusCode === StatusCode.InvalidArgument) { return false; } throw e; diff --git a/src/web/src/app/+accounts/account.component.html b/src/web/src/app/+accounts/account.component.html index 9bda7beb0..a43d2b32b 100644 --- a/src/web/src/app/+accounts/account.component.html +++ b/src/web/src/app/+accounts/account.component.html @@ -1,19 +1,19 @@ -
- Failed to load {{ webViewOptions.src }} page with {{ didFailLoadErrorDescription }} error code. - Reloading in {{ offlineIntervalRemainingSec }} sec. +
+ Failed to load {{ webViewAttributes.src }} page with {{ didFailLoadErrorDescription }} error code. + Reloading in {{ afterFailedLoadWait }} sec.
diff --git a/src/web/src/app/+accounts/account.component.ts b/src/web/src/app/+accounts/account.component.ts index 3d9b77db2..d7b3ead69 100644 --- a/src/web/src/app/+accounts/account.component.ts +++ b/src/web/src/app/+accounts/account.component.ts @@ -1,241 +1,312 @@ -import {BehaviorSubject, EMPTY, Observable, of, Subject} from "rxjs"; -import {AfterViewInit, Component, ElementRef, HostBinding, Input, NgZone, OnDestroy, ViewChild} from "@angular/core"; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + HostBinding, + Input, + NgZone, + OnDestroy, + ViewChild, +} from "@angular/core"; +import {BehaviorSubject, EMPTY, merge, Observable, of, Subject} from "rxjs"; +// tslint:disable-next-line:no-import-zones import {DidFailLoadEvent} from "electron"; -import {distinctUntilChanged, filter, map, pairwise, switchMap, takeUntil, withLatestFrom} from "rxjs/operators"; -import {equals, omit, pick} from "ramda"; -import {Store} from "@ngrx/store"; +import {filter, map, mergeMap, pairwise, take, takeUntil, tap, withLatestFrom} from "rxjs/operators"; +import {equals, pick} from "ramda"; +import {Action, Store} from "@ngrx/store"; import {AccountConfig, WebAccount} from "src/shared/model/account"; import {ACCOUNTS_ACTIONS, NAVIGATION_ACTIONS} from "src/web/src/app/store/actions"; import {APP_NAME, ONE_SECOND_MS} from "src/shared/constants"; -import {configUnreadNotificationsSelector, settingsKeePassClientConfSelector} from "src/web/src/app/store/reducers/options"; -import {ElectronContextLocations} from "src/shared/model/electron"; +import {ElectronService} from "src/web/src/app/+core/electron.service"; +import {getZoneNameBoundWebLogger} from "src/web/src/util"; import {KeePassRef} from "src/shared/model/keepasshttp"; -import {Omit} from "src/shared/types"; +import {OptionsSelectors} from "src/web/src/app/store/selectors"; import {State} from "src/web/src/app/store/reducers/accounts"; -export type WebViewPreloads = ElectronContextLocations["preload"]["webView"]; +let componentIndex = 0; @Component({ selector: "email-securely-app-account", templateUrl: "./account.component.html", styleUrls: ["./account.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AccountComponent implements AfterViewInit, OnDestroy { - // TODO simplify account$ initialization and usage - account$: BehaviorSubject; - // keepass - keePassClientConf$ = this.store.select(settingsKeePassClientConfSelector); +export class AccountComponent implements OnDestroy { passwordKeePassRef$: Observable; twoFactorCodeKeePassRef$: Observable; mailPasswordKeePassRef$: Observable; - // webview - @ViewChild("webViewRef", {read: ElementRef}) - webViewRef: ElementRef; - webViewOptions: { src: string; preload: string; }; - webViewPreloads: Omit; - webViewDomReadyHandlerArgs: ["dom-ready", typeof AccountComponent.prototype.webViewDomReadyEventHandler]; - webViewSrcSetupPromiseTrigger: () => void; - webViewSrcSetupPromise = new Promise((resolve) => this.webViewSrcSetupPromiseTrigger = resolve); - // offline - offlineIntervalStepSec = 10; - offlineIntervalAttempt = 0; - offlineIntervalHandle: any; + webViewAttributes: { src: string; preload: string; }; didFailLoadErrorDescription: string; @HostBinding("class.web-view-hidden") - offlineIntervalRemainingSec: number; - // other - credentialsFields: Array = ["credentials", "credentialsKeePass"]; - // releasing - unSubscribe$ = new Subject(); - releasingResolvers: Array<() => void> = []; - - constructor(private store: Store, - private zone: NgZone) { - this.webViewDomReadyHandlerArgs = ["dom-ready", this.webViewDomReadyEventHandler.bind(this)]; + afterFailedLoadWait: number; + keePassClientConf$ = this.store.select(OptionsSelectors.SETTINGS.keePassClientConf); + private account$: BehaviorSubject; + private logger: ReturnType; + private loggerZone: Zone; + @ViewChild("webViewRef", {read: ElementRef}) + private webViewElementRef: ElementRef; + private webViewPromiseTrigger: (webView: Electron.WebviewTag) => void; + private webViewPromise = new Promise((resolve) => this.webViewPromiseTrigger = resolve); + private unSubscribe$ = new Subject(); + private notificationChannelsReleasingTriggers: Array<() => void> = []; + + constructor( + private electron: ElectronService, + private store: Store, + private zone: NgZone, + private changeDetectorRef: ChangeDetectorRef, + ) { + const loggerPrefix = `[account.component][${componentIndex++}]`; + this.loggerZone = Zone.current.fork({name: loggerPrefix}); + this.logger = getZoneNameBoundWebLogger(loggerPrefix); + this.logger.info(`constructor()`); } - @Input() - set config(allWebViewPreloads: WebViewPreloads) { - if (!this.webViewOptions) { - this.webViewOptions = {src: "about:blank", preload: allWebViewPreloads.stub}; - } - this.webViewPreloads = omit([((name: keyof Pick) => name)("stub")], allWebViewPreloads); + get account(): WebAccount | null { + return this.account$ ? this.account$.value : null; } @Input() - set account(account: WebAccount) { + set account(account: WebAccount | null) { + if (!account) { + throw new Error(`"account" argument must be defined at this stage`); + } if (this.account$) { - const prevCredentials = pick(this.credentialsFields, this.account$.getValue().accountConfig); - const newCredentials = pick(this.credentialsFields, account.accountConfig); - - if (!equals(prevCredentials, newCredentials) && this.webView) { - this.store.dispatch(ACCOUNTS_ACTIONS.TryToLogin({account, webView: this.webView})); - } - this.account$.next(account); - } else { - this.account$ = new BehaviorSubject(account); - this.initAccountChangeReactions(); + return; } + this.account$ = new BehaviorSubject(account); + this.onAccountMounted(); } - get webView(): Electron.WebviewTag { - return this.webViewRef.nativeElement; + ngOnDestroy() { + this.logger.info(`ngOnDestroy()`); + this.unSubscribe$.next(); + this.unSubscribe$.complete(); + this.releaseNotificationChannels(); } - initAccountChangeReactions() { - this.account$ - .pipe( - map(({accountConfig}) => accountConfig), - distinctUntilChanged(({entryUrl: prev}, {entryUrl: curr}) => prev === curr), - takeUntil(this.unSubscribe$), - ) - .subscribe((accountConfig) => { - this.webViewOptions = { - src: accountConfig.entryUrl, - preload: this.webViewPreloads[accountConfig.type], - }; - this.webViewSrcSetupPromiseTrigger(); - }); - - this.account$ - .pipe( - map(({notifications}) => notifications.pageType && notifications.pageType.type), - filter((pageType) => pageType !== "undefined"), - distinctUntilChanged(), - takeUntil(this.unSubscribe$), - ) - .subscribe(() => { - this.store.dispatch(ACCOUNTS_ACTIONS.TryToLogin({account: this.account$.getValue(), webView: this.webView})); - }); - - this.account$ - .pipe( - map(({notifications, accountConfig}) => ({loggedIn: notifications.loggedIn, storeMails: accountConfig.storeMails})), - distinctUntilChanged((prev, curr) => equals(prev, curr)), // TODO => "distinctUntilChanged(equals)" - takeUntil(this.unSubscribe$), - ) - .subscribe(({loggedIn, storeMails}) => { - const account = this.account$.getValue(); - const login = account.accountConfig.login; - - if (loggedIn && storeMails) { - this.store.dispatch(ACCOUNTS_ACTIONS.ToggleFetching({ - account, - webView: this.webView, - finishPromise: this.buildReleasingPromise(), - })); - } else { - this.store.dispatch(ACCOUNTS_ACTIONS.ToggleFetching({login})); - } - }); + onKeePassPassword(password: string) { + this.logger.info(`onKeePassPassword()`); + // tslint:disable-next-line:no-floating-promises + this.webViewPromise.then((webView) => { + this.logger.info(`dispatch "TryToLogin" from onKeePassPassword()`); + this.dispatchInLoggerZone(ACCOUNTS_ACTIONS.TryToLogin({webView, account: this.account$.value, password})); + }); + } - this.account$ - .pipe( - withLatestFrom(this.store.select(configUnreadNotificationsSelector)), - filter(([account, unreadNotificationsEnabled]) => Boolean(unreadNotificationsEnabled)), - map(([{notifications}]) => notifications.unread), - pairwise(), - filter(([previousUnread, currentUnread]) => currentUnread > previousUnread), - takeUntil(this.unSubscribe$), - ) - .subscribe(([previousUnread, currentUnread]) => { - const login = this.account$.getValue().accountConfig.login; - const body = `Account "${login}" has ${currentUnread} unread email${currentUnread > 1 ? "s" : ""}.`; - - new Notification(APP_NAME, {body}).onclick = () => this.zone.run(() => { - this.store.dispatch(ACCOUNTS_ACTIONS.Activate({login})); - this.store.dispatch(NAVIGATION_ACTIONS.ToggleBrowserWindow({forcedState: true})); - }); - }); + private dispatchInLoggerZone(action: A) { + this.loggerZone.run(() => { + this.store.dispatch(action); + }); + } - this.passwordKeePassRef$ = this.account$.pipe(map(({accountConfig}) => { - return accountConfig.credentialsKeePass.password; - })); - this.twoFactorCodeKeePassRef$ = this.account$.pipe(map(({accountConfig}) => { - return accountConfig.credentialsKeePass.twoFactorCode; - })); - this.mailPasswordKeePassRef$ = this.account$.pipe(switchMap(({accountConfig}) => { + private onAccountMounted() { + this.logger.info(`onAccountMounted()`); + + this.passwordKeePassRef$ = this.account$.pipe(map((a) => a.accountConfig.credentialsKeePass.password)); + this.twoFactorCodeKeePassRef$ = this.account$.pipe(map((a) => a.accountConfig.credentialsKeePass.twoFactorCode)); + this.mailPasswordKeePassRef$ = this.account$.pipe(mergeMap(({accountConfig}) => { return accountConfig.type === "protonmail" ? of(accountConfig.credentialsKeePass.mailPassword) : EMPTY; })); - } - buildReleasingPromise() { - // tslint:disable-next-line:no-unused-expression - return new Promise((resolve) => { - this.releasingResolvers.push(resolve); + this.store.select(OptionsSelectors.FEATURED.electronLocations).pipe( + mergeMap((value) => value ? [value] : []), + map(({preload}) => preload.webView), + withLatestFrom(this.account$), + take(1), + ).subscribe(([webViewPreloads, {accountConfig}]) => { + this.webViewAttributes = {src: accountConfig.entryUrl, preload: webViewPreloads[accountConfig.type]}; + this.logger.verbose(`webview:attrs initial: "${this.webViewAttributes.src}"`); + this.changeDetectorRef.detectChanges(); + this.logger.info(`webview: mounted to DOM`); + this.onWebViewMounted(this.webViewElementRef.nativeElement); }); } - triggerReleasingResolvers() { - this.releasingResolvers.forEach((resolver) => resolver()); - } + private onWebViewMounted(webView: Electron.WebviewTag) { + this.logger.info(`onWebViewMounted()`); - onKeePassPassword(password: string) { - this.store.dispatch(ACCOUNTS_ACTIONS.TryToLogin({webView: this.webView, account: this.account$.getValue(), password})); - } + this.webViewPromiseTrigger(webView); - async ngAfterViewInit() { - await this.webViewSrcSetupPromise; + merge( + this.account$.pipe( + map(({accountConfig}) => accountConfig), + pairwise(), + filter(([{entryUrl: entryUrlPrev}, {entryUrl: entryUrlCurr}]) => entryUrlPrev !== entryUrlCurr), + map(([prev, curr]) => curr), + tap(({entryUrl}) => { + this.webViewAttributes.src = entryUrl; + this.logger.verbose(`webview:attrs url update to "${entryUrl}"`); + }), + ), + merge( + this.account$.pipe( + pairwise(), + filter(([{notifications: notificationsPrev}, {notifications: notificationsCurr}]) => { + const {pageType: pageTypePrev} = notificationsPrev; + const {pageType: pageTypeCurr} = notificationsCurr; + // "entryUrl" is changeable, so need to react on "url" change too + return pageTypeCurr.type !== "unknown" && !equals(pageTypePrev, pageTypeCurr); + }), + map(([accountPrev, accountCurr]) => accountCurr), + ), + this.account$.pipe( + pairwise(), + filter(([{accountConfig: accountConfigPrev}, {accountConfig: accountConfigCurr}]) => { + // TODO init "fields" once on the upper level + const fields: Array = ["credentials", "credentialsKeePass"]; + const prevFields = pick(fields, accountConfigPrev); + const currFields = pick(fields, accountConfigCurr); + return !equals(prevFields, currFields); + }), + map(([accountPrev, accountCurr]) => accountCurr), + ), + ).pipe( + tap((account) => { + this.logger.info(`dispatch "TryToLogin"`); + this.dispatchInLoggerZone(ACCOUNTS_ACTIONS.TryToLogin({account, webView})); + }), + ), + this.account$.pipe( + map(({notifications, accountConfig}) => ({loggedIn: notifications.loggedIn, storeMails: accountConfig.storeMails})), + pairwise(), + filter(([prev, curr]) => !equals(prev, curr)), + map(([prev, curr]) => curr), + withLatestFrom(this.account$), + tap(([{loggedIn, storeMails}, account]) => { + const login = account.accountConfig.login; + // TODO wire "ToggleFetching" back after the "triggerPromisesReleasing()" call + if (loggedIn && storeMails) { + this.dispatchInLoggerZone(ACCOUNTS_ACTIONS.ToggleFetching({ + account, + webView, + finishPromise: this.setupNotificationChannelReleasingTrigger(), + })); + return; + } + this.dispatchInLoggerZone(ACCOUNTS_ACTIONS.ToggleFetching({login})); + }), + ), + this.account$.pipe( + withLatestFrom(this.store.select(OptionsSelectors.CONFIG.unreadNotifications)), + filter(([account, unreadNotificationsEnabled]) => Boolean(unreadNotificationsEnabled)), + map(([account]) => account), + pairwise(), + filter(([{notifications: prev}, {notifications: curr}]) => curr.unread > prev.unread), + map(([prev, curr]) => curr), + tap(({accountConfig, notifications}) => { + const {login} = accountConfig; + const {unread} = notifications; + const body = `Account "${login}" has ${unread} unread email${unread > 1 ? "s" : ""}.`; + new Notification(APP_NAME, {body}).onclick = () => this.zone.run(() => { + this.dispatchInLoggerZone(ACCOUNTS_ACTIONS.Activate({login})); + this.dispatchInLoggerZone(NAVIGATION_ACTIONS.ToggleBrowserWindow({forcedState: true})); + }); + }), + ), + ).pipe( + takeUntil(this.unSubscribe$), + ).subscribe(() => { + // NOOP + }); // if ((process.env.NODE_ENV/* as BuildEnvironment*/) === "development") { - // this.webView.addEventListener("dom-ready", () => this.webView.openDevTools()); + // this.webView.addEventListener("dom-ready", () => webView.openDevTools()); // } - this.subscribePageLoadedEvents(); + this.configureWebView(webView); + } - this.webView.addEventListener("new-window", ({url}: any) => { - this.store.dispatch(NAVIGATION_ACTIONS.OpenExternal({url})); + private configureWebView(webView: Electron.WebviewTag) { + this.logger.info(`configureWebView()`); + + const domReadyEventHandler = () => { + this.logger.verbose(`webview:domReadyEventHandler(): "${webView.src}"`); + this.releaseNotificationChannels(); + + // TODO consider moving "notification" WebView API method back to the "accounts.effects" + const {value: account} = this.account$; + const {type, entryUrl, login} = account.accountConfig; + const finishPromise = this.setupNotificationChannelReleasingTrigger(); + const subscription = this.electron.webViewClient(webView, type, {finishPromise}) + .pipe( + mergeMap((caller) => caller("notification")({entryUrl, zoneName: this.logger.zoneName()})), + takeUntil(this.unSubscribe$), + ) + .subscribe((notification) => { + this.dispatchInLoggerZone(ACCOUNTS_ACTIONS.NotificationPatch({login, notification})); + }); + // tslint:disable-next-line:no-floating-promises + finishPromise.then(() => subscription.unsubscribe()); + }; + const arrayOfDomReadyEvenNameAndHandler = ["dom-ready", domReadyEventHandler]; + const subscribeDomReadyHandler = () => { + this.logger.info(`webview:subscribeDomReadyHandler()`); + webView.addEventListener.apply(webView, arrayOfDomReadyEvenNameAndHandler); + }; + const unsubscribeDomReadyHandler = () => { + this.logger.info(`webview:unsubscribeDomReadyHandler()`); + webView.removeEventListener.apply(webView, arrayOfDomReadyEvenNameAndHandler); + }; + + subscribeDomReadyHandler(); + this.unSubscribe$.pipe(take(1)).subscribe(unsubscribeDomReadyHandler); + + webView.addEventListener("new-window", ({url}: any) => { + this.dispatchInLoggerZone(NAVIGATION_ACTIONS.OpenExternal({url})); }); - - this.webView.addEventListener("did-fail-load", ({errorDescription}: DidFailLoadEvent) => { - // TODO figure ERR_NOT_IMPLEMENTED error cause, happening on password/2fa code submitting, tutanota only issue - if (errorDescription === "ERR_NOT_IMPLEMENTED" && this.account$.getValue().accountConfig.type === "tutanota") { - return; - } - - this.didFailLoadErrorDescription = errorDescription; - - this.unsubscribePageLoadingEvents(); - this.triggerReleasingResolvers(); - - this.offlineIntervalAttempt++; - this.offlineIntervalRemainingSec = Math.min(this.offlineIntervalStepSec * this.offlineIntervalAttempt, 60); - this.offlineIntervalHandle = setInterval(() => { - this.offlineIntervalRemainingSec--; - if (!this.offlineIntervalRemainingSec) { - clearInterval(this.offlineIntervalHandle); - this.subscribePageLoadedEvents(); - this.webView.reloadIgnoringCache(); + webView.addEventListener("did-fail-load", ((options: { attempt: number, stepSeconds: number }) => { + const setAfterFailedLoadWait = (value: number) => { + this.afterFailedLoadWait = value; + this.changeDetectorRef.detectChanges(); + }; + let intervalId: any = null; + + return ({errorDescription}: DidFailLoadEvent) => { + this.logger.verbose(`webview:did-fail-load: "${webView.src}"`); + + // TODO figure ERR_NOT_IMPLEMENTED error cause, happening on password/2fa code submitting, tutanota only issue + if (errorDescription === "ERR_NOT_IMPLEMENTED" && this.account$.value.accountConfig.type === "tutanota") { + return; } - }, ONE_SECOND_MS); - }); - } - subscribePageLoadedEvents() { - this.webView.addEventListener.apply(this.webView, this.webViewDomReadyHandlerArgs); - } + this.didFailLoadErrorDescription = errorDescription; - unsubscribePageLoadingEvents() { - this.webView.removeEventListener.apply(this.webView, this.webViewDomReadyHandlerArgs); - } + unsubscribeDomReadyHandler(); + this.releaseNotificationChannels(); - webViewDomReadyEventHandler() { - this.triggerReleasingResolvers(); + options.attempt++; - this.store.dispatch(ACCOUNTS_ACTIONS.SetupNotificationChannel({ - account: this.account$.getValue(), - webView: this.webView, - finishPromise: this.buildReleasingPromise(), - })); + setAfterFailedLoadWait(Math.min(options.stepSeconds * options.attempt, 60)); + this.changeDetectorRef.detectChanges(); + + intervalId = setInterval(() => { + setAfterFailedLoadWait(this.afterFailedLoadWait - 1); + this.changeDetectorRef.detectChanges(); + + if (this.afterFailedLoadWait > 0) { + return; + } + + clearInterval(intervalId); + subscribeDomReadyHandler(); + webView.reloadIgnoringCache(); + }, ONE_SECOND_MS); + }; + })({attempt: 0, stepSeconds: 10})); } - ngOnDestroy() { - this.unSubscribe$.next(); - this.unSubscribe$.complete(); + private setupNotificationChannelReleasingTrigger() { + this.logger.info(`setupNotificationChannelReleasingTrigger()`); + return new Promise((resolve) => this.notificationChannelsReleasingTriggers.push(resolve)); + } - this.unsubscribePageLoadingEvents(); - this.triggerReleasingResolvers(); + private releaseNotificationChannels() { + this.logger.info(`releaseNotificationChannels()`); + this.notificationChannelsReleasingTriggers.forEach((resolveTrigger) => resolveTrigger()); + // TODO remove executed functions form the array } } diff --git a/src/web/src/app/+accounts/accounts.component.html b/src/web/src/app/+accounts/accounts.component.html index fca8ae03c..1310d4901 100644 --- a/src/web/src/app/+accounts/accounts.component.html +++ b/src/web/src/app/+accounts/accounts.component.html @@ -12,7 +12,6 @@