├── .editorconfig ├── .env.example ├── .eslintrc ├── .github └── dependabot.yml ├── .gitignore ├── .gitlab-ci.yml ├── .nvmrc ├── .sentry.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ ├── plugin-interactive-tools.cjs │ │ └── plugin-version.cjs └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── app ├── assets │ └── icons │ │ ├── app │ │ ├── icon.png │ │ ├── icon@2x.png │ │ ├── icon@4x.png │ │ └── icon@8x.png │ │ ├── dock │ │ ├── icon-idle.png │ │ ├── icon-loading.png │ │ └── icon-tracking.png │ │ └── tray │ │ ├── icon-idle.png │ │ ├── icon-idle@1.25x.png │ │ ├── icon-idle@1.5x.png │ │ ├── icon-idle@2.5x.png │ │ ├── icon-idle@2x.png │ │ ├── icon-idle@3x.png │ │ ├── icon-idle@4x.png │ │ ├── icon-loading.png │ │ ├── icon-loading@1.25x.png │ │ ├── icon-loading@1.5x.png │ │ ├── icon-loading@2.5x.png │ │ ├── icon-loading@2x.png │ │ ├── icon-loading@3x.png │ │ ├── icon-loading@4x.png │ │ ├── icon-tracking.png │ │ ├── icon-tracking@1.25x.png │ │ ├── icon-tracking@1.5x.png │ │ ├── icon-tracking@2.5x.png │ │ ├── icon-tracking@2x.png │ │ ├── icon-tracking@3x.png │ │ └── icon-tracking@4x.png ├── renderer │ ├── app.html │ ├── fonts │ │ ├── Rubik │ │ │ ├── Rubik-Bold.ttf │ │ │ └── Rubik-Regular.ttf │ │ └── Source_Sans_Pro │ │ │ ├── SourceSansPro-Bold.ttf │ │ │ └── SourceSansPro-Regular.ttf │ ├── js │ │ ├── app.js │ │ ├── components │ │ │ ├── App.vue │ │ │ ├── Loader.vue │ │ │ ├── Message.vue │ │ │ ├── auth │ │ │ │ └── Login.vue │ │ │ ├── controls │ │ │ │ └── WindowControl.vue │ │ │ ├── inactivity │ │ │ │ └── Modal.vue │ │ │ └── user │ │ │ │ ├── ControlBar.vue │ │ │ │ ├── Navigation.vue │ │ │ │ ├── User.vue │ │ │ │ ├── intervals │ │ │ │ └── IntervalViewBig.vue │ │ │ │ ├── pages │ │ │ │ ├── IntervalsQueue.vue │ │ │ │ ├── OfflineSync.vue │ │ │ │ ├── Project.vue │ │ │ │ └── Settings.vue │ │ │ │ └── tasks │ │ │ │ ├── Create.vue │ │ │ │ ├── Info.vue │ │ │ │ ├── List.vue │ │ │ │ ├── Task.vue │ │ │ │ └── Tracker.vue │ │ ├── helpers │ │ │ ├── colors.helper.js │ │ │ ├── time-between.helper.js │ │ │ └── time-format.helper.js │ │ ├── router │ │ │ ├── index.js │ │ │ └── routes.js │ │ ├── storage │ │ │ ├── index.js │ │ │ └── store.js │ │ └── utils │ │ │ └── screenshot.js │ ├── screen-notie.html │ └── scss │ │ ├── app.scss │ │ ├── imports │ │ ├── _buttons.scss │ │ ├── _fonts.scss │ │ ├── _forms.scss │ │ ├── _grid.scss │ │ ├── _helpers.scss │ │ ├── _icons.scss │ │ ├── _main.scss │ │ ├── _nav.scss │ │ └── _variables.scss │ │ └── misc │ │ └── tasks-style-misc.scss └── src │ ├── app.js │ ├── base │ ├── active-window.js │ ├── api.js │ ├── authentication.js │ ├── config.js │ ├── deferred-handler.js │ ├── offline-mode.js │ ├── os-integration.js │ ├── task-tracker.js │ ├── translation.js │ ├── update.js │ └── user-preferences.js │ ├── components │ ├── application-menu.js │ ├── disable-production-hotkeys.js │ ├── index.js │ ├── log-rotate.js │ ├── os-inactivity-handler.js │ ├── power-manager.js │ ├── relaunch-on-logout.js │ ├── screen-notie.js │ ├── tray.js │ └── usage-statistic.js │ ├── constants │ ├── ScreenshotsState.js │ ├── empty-screenshot.js │ ├── log-rotate.js │ └── url.js │ ├── controller │ ├── offline-user.js │ ├── projects.js │ ├── tasks.js │ ├── time-intervals.js │ ├── time.js │ └── tracking-features.js │ ├── migrations │ ├── 20190401162647-create-project.js │ ├── 20190401185842-create-task.js │ ├── 20190401210750-create-interval.js │ ├── 20190401213740-create-track.js │ ├── 20190529213233-intervals-table-structure-changed-to-boundaries.js │ ├── 20190816003133-intervals-table-userid-added-.js │ ├── 20190816163400-create-property.js │ ├── 20200330060000-projects-source-prop.js │ ├── 20200709112600-change-interval-properties.js │ ├── 20200715105642-tasks-pinorder-property-added.js │ ├── 20210114201900-add-synced-flag-to-interval.js │ ├── 20210115220000-add-remoteid-to-interval.js │ ├── 20240000000001-change-interval-uuid-type.js │ └── 20240000000002-projects-add-screenshots-state.js │ ├── models │ ├── index.js │ ├── interval.js │ ├── project.js │ ├── property.js │ ├── task.js │ └── track.js │ ├── routes │ ├── authentication.js │ ├── index.js │ ├── intervals.js │ ├── misc.js │ ├── offline-mode.js │ ├── projects.js │ ├── task-tracking.js │ ├── tasks.js │ ├── time.js │ ├── translation.js │ └── user-preferences.js │ ├── translations │ ├── en.json │ └── ru.json │ └── utils │ ├── crypto-random.js │ ├── errors.js │ ├── event-counter.js │ ├── heartbeat-monitor.js │ ├── icons.js │ ├── jwt.js │ ├── keychain.js │ ├── log.js │ ├── notifier.js │ ├── screenshot.js │ ├── sentry.js │ ├── ticker.js │ ├── time.js │ └── translations.js ├── docker-desktop.png ├── package.json ├── resources ├── appx │ ├── LargeTile.png │ ├── SmallTile.png │ ├── Square150x150Logo.png │ ├── Square44x44Logo.png │ ├── StoreLogo.png │ └── Wide310x150Logo.png ├── entitlements.mac.inherit.plist ├── entitlements.mas.plist ├── icon.icns ├── icon.ico ├── icon.png └── icons │ ├── 1024x1024.png │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 512x512.png │ └── 64x64.png ├── tools ├── artifact-manifest.js ├── clean-development.js ├── macos-notarization.js └── rimraf.js ├── webpack.mix.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # API key & issuer exported from AppStore Connect 2 | # Required for Apple Notarization for app distribution 3 | APPLE_API_KEY=1234XXXXZZ 4 | APPLE_API_ISSUER=issuer-id-uuid-should-be-here 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "linebreak-style": ["error", "unix"] 4 | }, 5 | "parserOptions": { 6 | "ecmaVersion": "latest" 7 | }, 8 | 9 | "env": { 10 | "es6": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | .env.test 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # nuxt.js build output 72 | .nuxt 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless/ 79 | 80 | # FuseBox cache 81 | .fusebox/ 82 | 83 | # DynamoDB Local files 84 | .dynamodb/ 85 | 86 | # Ignore builds 87 | native/*.node 88 | 89 | # macOS 90 | .DS_Store 91 | 92 | # Idea 93 | .idea/ 94 | 95 | # Theming toolkit 96 | theming/ 97 | 98 | # Binary distribution 99 | target 100 | 101 | # Built frontend 102 | build 103 | 104 | .yarn/* 105 | !.yarn/patches 106 | !.yarn/plugins 107 | !.yarn/releases 108 | !.yarn/sdks 109 | !.yarn/versions 110 | .pnp.* 111 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - build 3 | - upload 4 | - deploy 5 | 6 | variables: 7 | PACKAGE_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/binaries/" 8 | 9 | build-linux: 10 | stage: build 11 | tags: 12 | - linux 13 | only: 14 | - tags 15 | image: node:fermium-slim 16 | artifacts: 17 | untracked: false 18 | expire_in: 30 days 19 | paths: 20 | - "target/*.AppImage" 21 | - "target/*.tar.gz" 22 | - "target/*.deb" 23 | - "target/latest-linux.yml" 24 | before_script: 25 | - apt update 26 | - apt install -y build-essential python3 pkg-config libsecret-1-0 libsecret-1-dev ca-certificates openssh-client dpkg-dev dpkg-sig 27 | - export RELEASE_VERSION=$(echo "${CI_COMMIT_TAG}" | sed 's/v//') 28 | - yarn 29 | - npm config set git-tag-version false 30 | - npm version $RELEASE_VERSION 31 | script: 32 | - yarn build-production 33 | 34 | - yarn package-linux 35 | 36 | build-mac: 37 | stage: build 38 | tags: 39 | - macos 40 | only: 41 | - tags 42 | artifacts: 43 | untracked: false 44 | expire_in: 30 days 45 | paths: 46 | - "target/*.dmg" 47 | - "target/latest-mac.yml" 48 | before_script: 49 | - source ~/.zshrc 50 | - security unlock-keychain -p "$APPLE_CI_HOST_PASSWORD" 51 | - export RELEASE_VERSION=$(echo "${CI_COMMIT_TAG}" | sed 's/v//') 52 | - yarn 53 | - npm config set git-tag-version false 54 | - npm version $RELEASE_VERSION 55 | script: 56 | - yarn build-production 57 | 58 | - yarn package-mac-unsigned 59 | 60 | build-windows: 61 | stage: build 62 | image: electronuserland/builder:14-wine 63 | tags: 64 | - docker 65 | only: 66 | - tags 67 | artifacts: 68 | untracked: false 69 | expire_in: 30 days 70 | paths: 71 | - "target/*.exe" 72 | - "target/*.appx" 73 | - "target/latest*.yml" 74 | before_script: 75 | - export RELEASE_VERSION=$(echo "${CI_COMMIT_TAG}" | sed 's/v//') 76 | - yarn 77 | - npm config set git-tag-version false 78 | - npm version $RELEASE_VERSION 79 | script: 80 | - yarn build-production 81 | - yarn package-windows 82 | 83 | upload: 84 | stage: upload 85 | only: 86 | - tags 87 | image: curlimages/curl:latest 88 | before_script: 89 | - export RELEASE_VERSION=$(echo "${CI_COMMIT_TAG}" | sed 's/v//') 90 | script: 91 | - 'cd target && find . -maxdepth 1 -type f | while read -r line; do curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file "${line}" "${PACKAGE_URL}${RELEASE_VERSION}/${result}"; done' 92 | 93 | release: 94 | stage: deploy 95 | only: 96 | - tags 97 | when: manual 98 | image: registry.gitlab.com/gitlab-org/release-cli:latest 99 | before_script: 100 | - export RELEASE_VERSION=$(echo "${CI_COMMIT_TAG}" | sed 's/v//') 101 | script: 102 | - | 103 | release-cli create --name $CI_COMMIT_TAG --tag-name $CI_COMMIT_TAG \ 104 | --assets-link "{\"name\":\"dmg\",\"filepath\":\"/dmg\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/Cattr.dmg\", \"link_type\":\"package\"}" \ 105 | --assets-link "{\"name\":\"exe\",\"filepath\":\"/exe\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/Cattr.exe\", \"link_type\":\"package\"}" \ 106 | --assets-link "{\"name\":\"nsis\",\"filepath\":\"/nsis\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/Cattr_Setup.exe\", \"link_type\":\"package\"}" \ 107 | --assets-link "{\"name\":\"appx\",\"filepath\":\"/appx\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/Cattr.appx\", \"link_type\":\"package\"}" \ 108 | --assets-link "{\"name\":\"tar\",\"filepath\":\"/tar\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/Cattr.tar.gz\", \"link_type\":\"package\"}" \ 109 | --assets-link "{\"name\":\"deb\",\"filepath\":\"/deb\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/Cattr.deb\", \"link_type\":\"package\"}" \ 110 | --assets-link "{\"name\":\"appimage\",\"filepath\":\"/appimage\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/Cattr.AppImage\", \"link_type\":\"package\"}" \ 111 | --assets-link "{\"name\":\"windows-yml\",\"filepath\":\"/latest.yml\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/latest.yml\", \"link_type\":\"other\"}" \ 112 | --assets-link "{\"name\":\"mac-yml\",\"filepath\":\"/latest-mac.yml\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/latest-mac.yml\", \"link_type\":\"other\"}" \ 113 | --assets-link "{\"name\":\"linux-yml\",\"filepath\":\"/latest-linux.yml\",\"url\":\"${PACKAGE_URL}${RELEASE_VERSION}/latest-linux.yml\", \"link_type\":\"other\"}" 114 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.19.0 2 | -------------------------------------------------------------------------------- /.sentry.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://sentry.amazingcat.net", 3 | "org": "amazingcat", 4 | "frontend": { 5 | "project": "cattr-desktop-vue" 6 | }, 7 | "backend": { 8 | "project": "cattr-desktop-electron" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | npmScopes: 4 | cattr: 5 | npmRegistryServer: "https://git.amazingcat.net/api/v4/packages/npm/" 6 | 7 | plugins: 8 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 9 | spec: "@yarnpkg/plugin-interactive-tools" 10 | - path: .yarn/plugins/@yarnpkg/plugin-version.cjs 11 | spec: "@yarnpkg/plugin-version" 12 | 13 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Cattr Desktop App 2 | ========== 3 | Electron desktop application for Cattr 4 | 5 | Minimum system requirements to build the app 6 | - MacOS: Monterey 12.3.1 7 | - Windows: 22H2 10.0.19045, 11.0.22621 8 | - Debian: bullseye+kde 11 9 | - Ubuntu: LTS 22.04 10 | - Alt linux: kworkstation 10 11 | - Astra linux: orel 2.12 12 | - CPU: amd64 13 | 14 | ### For build to work, you need to have following dependencies: 15 | #### MacOS 16 | You need to install xcode from [official website](https://developer.apple.com/xcode/) 17 | 18 | #### Linux (apt based) 19 | ```bash 20 | apt-get update 21 | apt-get install -y git cmake curl python3 build-essential pkg-config libsecret-1-0 libsecret-1-dev ca-certificates openssh-client dpkg-dev dpkg-sig 22 | ``` 23 | ##### Installl nodejs 14.19.0 (MacOS & Linux) 24 | Easiest way to do so is by using nvm, here is the [official guide on how to install it](https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script). 25 | 26 | Now we can use it to install nodejs. 27 | ```bash 28 | nvm install 14.19.0 29 | nvm use 14.19.0 30 | ``` 31 | Install yarn 32 | ```bash 33 | npm install -g yarn 34 | ``` 35 | 36 | You can verify the installation like so: 37 | ```bash 38 | node -v # v14.19.0 39 | yarn -v # 3.2.1 40 | ``` 41 | 42 | #### Windows 43 | ##### Download and install Docker Desktop from the [official site](https://www.docker.com/). 44 | 45 | ![docker](docker-desktop.png) 46 | 47 | For Docker to work in Windows you may need to enable virtualization in BIOS and [install WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install). The installation process is described in details [in the Docker user manual](https://docs.docker.com/desktop/setup/install/windows-install/). 48 | 49 | 50 | ## Launch development version (Linux & MacOS only) 51 | 1. Clone this repository and open it's directory 52 | 2. Install dependencies via `yarn` 53 | 3. Specify version, for example `v1.0.0"` 54 | ```bash 55 | npm config set git-tag-version false 56 | npm version v1.0.0 57 | ``` 58 | 4. Run webpack via `yarn build-development` for development version 59 | 5. When build completes, run `yarn dev` to launch client in development mode 60 | 61 | ## Development mode 62 | Development installation uses different keychain service name and application folder path (with "-develop" suffix). 63 | 64 | ## Build production version 65 | 1. Clone this repository and open its directory 66 | 2. (Windows only) run in PowerShell `docker run -it -v ${PWD}:/project electronuserland/builder:14-wine` next commands should be executed inside running container. 67 | 3. Install dependencies via `yarn` 68 | 4. Specify version, for example `v1.0.0` 69 | ```bash 70 | npm config set git-tag-version false 71 | npm version v1.0.0 72 | ``` 73 | 5. Build application in production mode via `yarn build-production` 74 | 6. Build executable for your favourite platform (output directory is `/target`). 75 | 76 | 77 | How to build executable? 78 | - **macOS:** `yarn package-mac` will produce signed & notarized DMG 79 | - **Linux:** `yarn package-linux` will produce Tarball, DPKG and AppImage 80 | - **Windows:** `yarn package-windows` will produce installer and portable executables 81 | 82 | Compatibility sheet: 83 | - **Host with macOS:** can produce builds only for macOS 84 | - **Host with Linux:** can produce builds for Linux and Windows (using Wine) 85 | - **Host with Windows:** can produce builds only for Windows 86 | -------------------------------------------------------------------------------- /app/assets/icons/app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/app/icon.png -------------------------------------------------------------------------------- /app/assets/icons/app/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/app/icon@2x.png -------------------------------------------------------------------------------- /app/assets/icons/app/icon@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/app/icon@4x.png -------------------------------------------------------------------------------- /app/assets/icons/app/icon@8x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/app/icon@8x.png -------------------------------------------------------------------------------- /app/assets/icons/dock/icon-idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/dock/icon-idle.png -------------------------------------------------------------------------------- /app/assets/icons/dock/icon-loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/dock/icon-loading.png -------------------------------------------------------------------------------- /app/assets/icons/dock/icon-tracking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/dock/icon-tracking.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-idle.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-idle@1.25x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-idle@1.25x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-idle@1.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-idle@1.5x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-idle@2.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-idle@2.5x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-idle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-idle@2x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-idle@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-idle@3x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-idle@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-idle@4x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-loading.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-loading@1.25x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-loading@1.25x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-loading@1.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-loading@1.5x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-loading@2.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-loading@2.5x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-loading@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-loading@2x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-loading@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-loading@3x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-loading@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-loading@4x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-tracking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-tracking.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-tracking@1.25x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-tracking@1.25x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-tracking@1.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-tracking@1.5x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-tracking@2.5x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-tracking@2.5x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-tracking@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-tracking@2x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-tracking@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-tracking@3x.png -------------------------------------------------------------------------------- /app/assets/icons/tray/icon-tracking@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/assets/icons/tray/icon-tracking@4x.png -------------------------------------------------------------------------------- /app/renderer/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cattr 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/renderer/fonts/Rubik/Rubik-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/renderer/fonts/Rubik/Rubik-Bold.ttf -------------------------------------------------------------------------------- /app/renderer/fonts/Rubik/Rubik-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/renderer/fonts/Rubik/Rubik-Regular.ttf -------------------------------------------------------------------------------- /app/renderer/fonts/Source_Sans_Pro/SourceSansPro-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/renderer/fonts/Source_Sans_Pro/SourceSansPro-Bold.ttf -------------------------------------------------------------------------------- /app/renderer/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/app/renderer/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf -------------------------------------------------------------------------------- /app/renderer/js/app.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron'; 2 | import Vue from 'vue'; 3 | import IPCRouter from '@amazingcat/electron-ipc-router'; 4 | import * as Sentry from '@sentry/browser'; 5 | import * as Integrations from '@sentry/integrations'; 6 | import VueI18n from 'vue-i18n'; 7 | import Element from 'element-ui'; 8 | 9 | import store from './storage'; 10 | import App from './components/App.vue'; 11 | import router from './router'; 12 | 13 | // Comment it out to use remote devtools 14 | if (process.env.NODE_ENV === 'development' && process.env.REMOTE_DEVTOOLS_ENABLE) { 15 | 16 | try { 17 | 18 | // eslint-disable-next-line global-require 19 | // const devtools = require('@vue/devtools'); 20 | // devtools.connect(); 21 | 22 | // eslint-disable-next-line no-console 23 | console.log('vue-devtools package is disabled due to the maintaining issues'); 24 | 25 | } catch (err) { 26 | 27 | // eslint-disable-next-line no-console 28 | console.error('Error occured during Vue Devtools init', err); 29 | 30 | } 31 | 32 | } 33 | 34 | Vue.use(VueI18n); 35 | Vue.use(Element); 36 | 37 | (async () => { 38 | 39 | // Initialize IPC 40 | Vue.prototype.$ipc = new IPCRouter(ipcRenderer); 41 | 42 | // Initialise Sentry 43 | (() => { 44 | 45 | // eslint-disable-next-line no-restricted-globals 46 | let sentryConfig = new URL(location.href); 47 | if (!sentryConfig.searchParams.has('sentry')) 48 | return; 49 | 50 | // Extract Sentry configuration from query params 51 | sentryConfig = JSON.parse(sentryConfig.searchParams.get('sentry')); 52 | 53 | if (!sentryConfig.enabled) 54 | return; 55 | 56 | Sentry.init({ 57 | dsn: sentryConfig.dsnFrontend, 58 | release: sentryConfig.release, 59 | integrations: [new Integrations.Vue({ Vue, attachProps: true })], 60 | 61 | // Patching error report right before sending to normalize frontend paths 62 | beforeSend: data => { 63 | 64 | // Filter only requests with known structure which we can modify 65 | if (!data || !data.exception || !data.exception.values) 66 | return data; 67 | 68 | // Iterate over exceptions 69 | // eslint-disable-next-line no-param-reassign 70 | data.exception.values = data.exception.values.map(exception => { 71 | 72 | // Filter only exceptions we can modify 73 | if (!exception.stacktrace || !exception.stacktrace.frames) 74 | return exception; 75 | 76 | // Rewrite exception file path to "build/app.js" since we always 77 | // building frontend into the single bundle 78 | // eslint-disable-next-line no-param-reassign 79 | exception.stacktrace.frames = exception.stacktrace.frames.map(frame => { 80 | 81 | // Modify only exceptions with the "filename" property 82 | if (frame.filename) 83 | frame.filename = 'build/app.js'; // eslint-disable-line no-param-reassign 84 | 85 | return frame; 86 | 87 | }); 88 | 89 | return exception; 90 | 91 | }); 92 | 93 | return data; 94 | 95 | }, 96 | }); 97 | 98 | // Populate Sentry error reports with company identifier 99 | Vue.prototype.$ipc.serve( 100 | 'auth/company-instance-fetched', 101 | req => Sentry.configureScope(scope => scope.setTag('companyIdentifier', req.packet.body.cid)), 102 | ); 103 | 104 | 105 | })(); 106 | 107 | // Initialise translations 108 | const i18n = await (async () => { 109 | 110 | const { body } = await Vue.prototype.$ipc.request('translation/get-configuration', {}); 111 | const { lng, resources } = body.configuration; 112 | const messages = {}; 113 | 114 | Object.entries(resources).forEach(([key, value]) => { 115 | 116 | messages[key] = value.translation; 117 | 118 | }); 119 | 120 | return new VueI18n({ locale: lng, silentTranslationWarn: true, messages }); 121 | 122 | })(); 123 | 124 | new Vue({ 125 | router, 126 | store, 127 | i18n, 128 | render: h => h(App), 129 | }).$mount('#app'); 130 | 131 | })(); 132 | 133 | -------------------------------------------------------------------------------- /app/renderer/js/components/Loader.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | 30 | 47 | -------------------------------------------------------------------------------- /app/renderer/js/components/Message.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | 24 | 27 | -------------------------------------------------------------------------------- /app/renderer/js/components/controls/WindowControl.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 44 | 45 | 66 | -------------------------------------------------------------------------------- /app/renderer/js/components/user/Navigation.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | 28 | 31 | -------------------------------------------------------------------------------- /app/renderer/js/components/user/pages/IntervalsQueue.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 88 | 89 | 138 | -------------------------------------------------------------------------------- /app/renderer/js/components/user/pages/Project.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 94 | 95 | 188 | -------------------------------------------------------------------------------- /app/renderer/js/components/user/tasks/Create.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 175 | -------------------------------------------------------------------------------- /app/renderer/js/helpers/colors.helper.js: -------------------------------------------------------------------------------- 1 | export function addLight(color, amount) { 2 | 3 | const cc = parseInt(color, 16) + amount; 4 | let c = (cc > 255) ? 255 : (cc); 5 | c = (c.toString(16).length > 1) ? c.toString(16) : `0${c.toString(16)}`; 6 | return c; 7 | 8 | } 9 | 10 | export function lighten(rawColor, rawAmount) { 11 | 12 | const color = (rawColor.includes('#')) ? rawColor.substring(1, rawColor.length) : rawColor; 13 | const amount = parseInt((255 * rawAmount) / 100, 10); 14 | return `#${addLight(color.substring(0, 2), amount)}${addLight(color.substring(2, 4), amount)}${addLight(color.substring(4, 6), amount)}`; 15 | 16 | } 17 | 18 | export function subtractLight(color, amount) { 19 | 20 | const cc = parseInt(color, 16) - amount; 21 | let c = (cc < 0) ? 0 : (cc); 22 | c = (c.toString(16).length > 1) ? c.toString(16) : `0${c.toString(16)}`; 23 | return c; 24 | 25 | } 26 | 27 | export function darken(rawColor, rawAmount) { 28 | 29 | const color = (rawColor.includes('#')) ? rawColor.substring(1, rawColor.length) : rawColor; 30 | const amount = parseInt((255 * rawAmount) / 100, 10); 31 | return `#${subtractLight(color.substring(0, 2), amount)}${subtractLight(color.substring(2, 4), amount)}${subtractLight(color.substring(4, 6), amount)}`; 32 | 33 | } 34 | -------------------------------------------------------------------------------- /app/renderer/js/helpers/time-between.helper.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | 3 | /** 4 | * Delta between dates in seconds 5 | * @param {Date} dateA 6 | * @param {Date} dateB 7 | * @returns {Number} Delta in seconds 8 | */ 9 | export function secondsBetween(dateA, dateB) { 10 | 11 | return Math.ceil(Math.abs(new Date(dateA).getTime() - new Date(dateB).getTime()) / 1000); 12 | 13 | } 14 | -------------------------------------------------------------------------------- /app/renderer/js/helpers/time-format.helper.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | 3 | /** 4 | * Adding a leading zero 5 | * @param {Number} number Input number 6 | * @return {String} String with lead zero if neccessary 7 | */ 8 | const leftpad = number => ((number >= 10) ? String(number) : `0${number}`); 9 | 10 | /** 11 | * Converts amount of seconds into HH:mm:ss format 12 | * @param {Number} seconds Amount of seconds 13 | * @returns {String} Formatted amount of seconds 14 | */ 15 | export function formatSeconds(seconds) { 16 | 17 | // Parse input argument and perform type-safety checks 18 | let secs = Number(seconds); 19 | if (Number.isNaN(secs)) 20 | throw new TypeError('Incorrect seconds amount'); 21 | else if (secs < 0) 22 | throw new TypeError('Expect positive number of seconds, but negative is given'); 23 | else if (secs === 0) 24 | return '00:00:00'; 25 | 26 | // Getting amount of hours 27 | const hours = Math.floor(secs / 3600); 28 | secs %= 3600; 29 | 30 | // Getting amount of seconds 31 | const minutes = Math.floor(secs / 60); 32 | secs %= 60; 33 | 34 | return `${leftpad(hours)}:${leftpad(minutes)}:${leftpad(secs)}`; 35 | 36 | } 37 | 38 | /** 39 | * Splits seconds into hours-minutes-seconds 40 | * @param {Number} seconds 41 | * @returns {Object} 42 | */ 43 | export function splitSecondsIntoHMS(seconds) { 44 | 45 | let secs = Number(seconds); 46 | 47 | // Getting amount of hours 48 | const hours = Math.floor(secs / 3600); 49 | secs %= 3600; 50 | 51 | // Getting amount of seconds 52 | const minutes = Math.floor(secs / 60); 53 | secs %= 60; 54 | 55 | return { hours, minutes, seconds: secs }; 56 | 57 | } 58 | -------------------------------------------------------------------------------- /app/renderer/js/router/index.js: -------------------------------------------------------------------------------- 1 | import Router from 'vue-router'; 2 | import Vue from 'vue'; 3 | import routes from './routes'; 4 | 5 | Vue.use(Router); 6 | 7 | export default new Router({ 8 | mode: 'history', 9 | routes, 10 | }); 11 | -------------------------------------------------------------------------------- /app/renderer/js/router/routes.js: -------------------------------------------------------------------------------- 1 | import Login from '../components/auth/Login.vue'; 2 | import User from '../components/user/User.vue'; 3 | import TaskList from '../components/user/tasks/List.vue'; 4 | import UserSettings from '../components/user/pages/Settings.vue'; 5 | import OfflineSync from "../components/user/pages/OfflineSync.vue"; 6 | import Info from '../components/user/tasks/Info.vue'; 7 | import TaskCreate from '../components/user/tasks/Create.vue'; 8 | import Project from '../components/user/pages/Project.vue'; 9 | import IntervalsQueue from '../components/user/pages/IntervalsQueue.vue'; 10 | 11 | export default [ 12 | { 13 | name: 'auth.login', 14 | path: '/auth/login', 15 | component: Login, 16 | }, 17 | { 18 | name: 'user', 19 | path: '/user', 20 | component: User, 21 | beforeEnter: (to, from, next) => next(), // TODO 22 | children: [ 23 | { 24 | name: 'user.settings', 25 | path: 'settings', 26 | component: UserSettings, 27 | }, 28 | { 29 | name: 'user.offline-sync', 30 | path: 'offline-sync', 31 | component: OfflineSync, 32 | }, 33 | { 34 | name: 'user.tasks', 35 | path: 'tasks', 36 | component: TaskList, 37 | }, 38 | { 39 | name: 'user.task', 40 | path: 'task/:id', 41 | component: Info, 42 | }, 43 | { 44 | name: 'user.project', 45 | path: 'project/:id', 46 | component: Project, 47 | }, 48 | { 49 | name: 'user.createTask', 50 | path: 'create', 51 | component: TaskCreate, 52 | }, 53 | { 54 | name: 'user.intervalsQueue', 55 | path: 'queue', 56 | component: IntervalsQueue, 57 | }, 58 | ], 59 | }, 60 | ]; 61 | -------------------------------------------------------------------------------- /app/renderer/js/storage/index.js: -------------------------------------------------------------------------------- 1 | import Vuex from 'vuex'; 2 | import Vue from 'vue'; 3 | import store from './store'; 4 | 5 | Vue.use(Vuex); 6 | 7 | export default new Vuex.Store(store); 8 | -------------------------------------------------------------------------------- /app/renderer/js/utils/screenshot.js: -------------------------------------------------------------------------------- 1 | const { desktopCapturer } = require('electron'); 2 | 3 | /** 4 | * Returns available "display" MediaDevices 5 | * @return {Promise|Error>} Return array of MediaDevice, or error 6 | */ 7 | const getMediaDevices = () => desktopCapturer.getSources({ types: ['screen'] }); 8 | 9 | /** 10 | * Captures screen 11 | * @param {DOMElement} canvas Canvas element 12 | * @return {Promise|null>} Captured screenshots in base64 or null 13 | */ 14 | export default async canvas => { 15 | 16 | // Getting available MediaDevices 17 | const mediaDevices = await getMediaDevices(); 18 | 19 | // Getting MediaStream from all fetched devices 20 | let userMediaStreams = mediaDevices.map(device => { 21 | 22 | // Fix 23 | if (device.stream) 24 | device.stream.stop(); 25 | 26 | return navigator.mediaDevices.getUserMedia({ 27 | audio: false, 28 | video: { 29 | mandatory: { 30 | chromeMediaSource: 'desktop', 31 | chromeMediaSourceId: device.id, 32 | }, 33 | }, 34 | }); 35 | 36 | }); 37 | 38 | // Filter all unsuccessfull media streams 39 | userMediaStreams = userMediaStreams.filter(stream => typeof stream !== 'undefined'); 40 | 41 | // Resolve all MediaStreams inits 42 | userMediaStreams = await Promise.all(userMediaStreams); 43 | 44 | // Grabbing frams for all MediaStreams 45 | let userMediaGrabbers = userMediaStreams.map(mediaStream => { 46 | 47 | const mediaStreamTrack = mediaStream.getVideoTracks()[0]; 48 | return new ImageCapture(mediaStreamTrack).grabFrame(); 49 | 50 | }); 51 | 52 | // Making screenshots 53 | userMediaGrabbers = await Promise.all(userMediaGrabbers); 54 | 55 | // Closing MediaStreams 56 | userMediaStreams.forEach(mediaStream => mediaStream.getVideoTracks()[0].stop()); 57 | 58 | // Getting canvas size for splitted screenshots 59 | const canvasSize = userMediaGrabbers 60 | 61 | // Get W&H for each snap 62 | .map(snap => ([snap.width, snap.height])) 63 | 64 | // Sum them 65 | .reduce((dimensions, [width, height]) => { 66 | 67 | // Selecting max height over each screen 68 | if (height > dimensions.height) 69 | dimensions.height = height; // eslint-disable-line no-param-reassign 70 | 71 | // Summarize width 72 | dimensions.width += width; // eslint-disable-line no-param-reassign 73 | 74 | return dimensions; 75 | 76 | }, { width: 0, height: 0 }); 77 | 78 | // Creating canvas with the same image size 79 | canvas.width = canvasSize.width; // eslint-disable-line no-param-reassign 80 | canvas.height = canvasSize.height; // eslint-disable-line no-param-reassign 81 | 82 | // Obtaining BitmapRenderer context 83 | const ctx = canvas.getContext('2d'); 84 | 85 | // Placing snaps as ImageBitmaps on BitmapRenderer context 86 | let lastSnapEndedX = 0; 87 | userMediaGrabbers = userMediaGrabbers.forEach(snap => { 88 | 89 | ctx.drawImage(snap, lastSnapEndedX, 0); 90 | lastSnapEndedX += snap.width; 91 | 92 | }); 93 | 94 | // Return rendered canvas as JPEG image with 50% quality in DataURL (base64) 95 | return canvas.toDataURL('image/jpeg', 0.5); 96 | 97 | }; 98 | -------------------------------------------------------------------------------- /app/renderer/screen-notie.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cattr 8 | 88 | 89 | 90 | 91 |
92 |
95 | 190 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /app/renderer/scss/app.scss: -------------------------------------------------------------------------------- 1 | @import "imports/variables"; 2 | @import "~element-ui/packages/theme-chalk/src/index"; 3 | @import "imports/fonts"; 4 | @import "imports/main"; 5 | @import "imports/icons"; 6 | // It's just a hook for the clickable tasks and projects buttons 7 | @import "misc/tasks-style-misc"; 8 | -------------------------------------------------------------------------------- /app/renderer/scss/imports/_buttons.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | color: $font-color-primary; 3 | font-size: $font-size-base; 4 | padding: .5em 1.5em; 5 | cursor: pointer; 6 | 7 | font-family: $font-family-headings; 8 | 9 | border-radius: .25em; 10 | transition: ease .09s; 11 | border: 0; 12 | 13 | &:active { 14 | outline: none; 15 | } 16 | 17 | &:focus { 18 | outline: none; 19 | } 20 | 21 | &.btn-primary { 22 | background: $btn-color-primary; 23 | 24 | &:active { 25 | background: darken($btn-color-primary, 15%); 26 | } 27 | } 28 | 29 | &.btn-secondary { 30 | background: $btn-color-secondary; 31 | 32 | &:active { 33 | background: darken($btn-color-secondary, 10%); 34 | } 35 | } 36 | 37 | &.btn-outlined-primary { 38 | background: transparent; 39 | border: 2px solid $btn-color-primary; 40 | 41 | &:active { 42 | border-color: darken($btn-color-primary, 15%); 43 | } 44 | } 45 | 46 | &.btn-outlined-secondary { 47 | background: transparent; 48 | border: 2px solid $btn-color-secondary; 49 | 50 | &:active { 51 | border-color: darken($btn-color-secondary, 15%); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/renderer/scss/imports/_fonts.scss: -------------------------------------------------------------------------------- 1 | /* cyrillic */ 2 | @font-face { 3 | font-family: 'Rubik'; 4 | font-style: normal; 5 | font-weight: 400; 6 | font-display: swap; 7 | src: local('Rubik'), local('Rubik-Regular'), url('fonts/Rubik-Regular.ttf') format('truetype'); 8 | } 9 | 10 | /* cyrillic */ 11 | @font-face { 12 | font-family: 'Rubik'; 13 | font-style: normal; 14 | font-weight: 700; 15 | font-display: swap; 16 | src: local('Rubik Bold'), local('Rubik-Bold'), url('fonts/Rubik-Bold.ttf') format('truetype'); 17 | } 18 | 19 | /* cyrillic */ 20 | @font-face { 21 | font-family: 'Source Sans Pro'; 22 | font-style: normal; 23 | font-weight: 400; 24 | font-display: swap; 25 | src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url('fonts/SourceSansPro-Regular.ttf') format('truetype'); 26 | } 27 | 28 | /* cyrillic */ 29 | @font-face { 30 | font-family: 'Source Sans Pro'; 31 | font-style: normal; 32 | font-weight: 700; 33 | font-display: swap; 34 | src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url('fonts/SourceSansPro-Bold.ttf') format('truetype'); 35 | } 36 | -------------------------------------------------------------------------------- /app/renderer/scss/imports/_forms.scss: -------------------------------------------------------------------------------- 1 | .form-row { 2 | display: flex; 3 | flex-direction: column; 4 | 5 | .form-input { 6 | padding: .5em .5em; 7 | font-size: 1em; 8 | /*border-radius: 1em;*/ 9 | margin-bottom: 1em; 10 | border: 0; 11 | border-bottom: 1px solid $border-color-primary; 12 | background: transparent; 13 | color: #fafafa; 14 | 15 | /*box-shadow: 0 5px .3em lighten(rgba(24, 24, 38, .3), 15%);*/ 16 | 17 | &:focus { 18 | // border: 0; 19 | outline: 0 20 | } 21 | 22 | &::placeholder { 23 | color: darken(#fafafa, 15%); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/renderer/scss/imports/_grid.scss: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | flex-flow: row nowrap; 4 | margin-bottom: 1em; 5 | &:last-child { 6 | margin: 0; 7 | } 8 | 9 | .col { 10 | flex-basis: auto; 11 | 12 | &-1 { 13 | flex-basis: 8.33333333333%; 14 | max-width: 8.33333333333%; 15 | } 16 | 17 | &-2 { 18 | flex-basis: 16.6666666667%; 19 | max-width: 16.6666666667%; 20 | } 21 | 22 | &-3 { 23 | flex-basis: 25%; 24 | max-width: 25%; 25 | 26 | } 27 | 28 | &-4 { 29 | flex-basis: 33.3333333333%; 30 | max-width: 33.3333333333%; 31 | 32 | } 33 | 34 | &-5 { 35 | flex-basis: 41.6666666667%; 36 | max-width: 41.6666666667%; 37 | 38 | } 39 | 40 | &-6 { 41 | flex-basis: 50%; 42 | max-width: 50% 43 | 44 | } 45 | 46 | &-7 { 47 | flex-basis: 58.3333333333%; 48 | max-width: 58.3333333333%; 49 | 50 | } 51 | 52 | &-8 { 53 | flex-basis: 66.6666666667%; 54 | max-width: 66.6666666667%; 55 | 56 | } 57 | 58 | &-9 { 59 | flex-basis: 75%; 60 | max-width: 75%; 61 | 62 | } 63 | 64 | &-10 { 65 | flex-basis: 83.3333333333%; 66 | max-width: 83.3333333333%; 67 | 68 | } 69 | 70 | &-11 { 71 | flex-basis: 91.6666666667%; 72 | max-width: 91.6666666667%; 73 | 74 | } 75 | 76 | &-12 { 77 | flex-basis: 100%; 78 | max-width: 100%; 79 | } 80 | 81 | &-1, &-2, &-3, &-4, &-5, &-6, &-7, &-8, &-9, &-10, &-11, &-12 { 82 | box-sizing: border-box; 83 | padding: 0 .5em; 84 | 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/renderer/scss/imports/_helpers.scss: -------------------------------------------------------------------------------- 1 | .text { 2 | &-center { 3 | text-align: center; 4 | } 5 | 6 | &-right { 7 | text-align: right; 8 | } 9 | 10 | &-left { 11 | text-align: left; 12 | } 13 | } 14 | 15 | .justify-content { 16 | &-left { 17 | justify-content: flex-start; 18 | } 19 | 20 | &-center { 21 | justify-content: center; 22 | } 23 | 24 | &-right { 25 | justify-content: flex-end; 26 | } 27 | } 28 | 29 | .align-items { 30 | &-top { 31 | align-items: flex-start; 32 | } 33 | 34 | &-center { 35 | align-items: center; 36 | } 37 | 38 | &-top { 39 | align-items: flex-end; 40 | } 41 | } 42 | 43 | .d { 44 | &-flex { 45 | display: flex; 46 | } 47 | 48 | &-inline-flex { 49 | display: inline-flex; 50 | } 51 | } 52 | 53 | .p { 54 | &-1 { 55 | padding: .5em !important; 56 | } 57 | 58 | &-2 { 59 | padding: 1em !important; 60 | } 61 | 62 | &-3 { 63 | padding: 1.5em !important; 64 | } 65 | 66 | &-4 { 67 | padding: 1.75em !important; 68 | } 69 | 70 | &-5 { 71 | padding: 2em !important; 72 | } 73 | 74 | &t { 75 | &-1 { 76 | padding-top: .5em !important; 77 | } 78 | 79 | &-2 { 80 | padding-top: 1em !important; 81 | } 82 | 83 | &-3 { 84 | padding-top: 1.5em !important; 85 | } 86 | 87 | &-4 { 88 | padding-top: 1.75em !important; 89 | } 90 | 91 | &-5 { 92 | padding-top: 2em !important; 93 | } 94 | } 95 | } 96 | 97 | .underlined { 98 | padding-bottom: .5em; 99 | border-bottom: 1px solid $border-color-primary; 100 | } 101 | 102 | .label { 103 | margin-bottom: .5em; 104 | } 105 | 106 | .text-muted { 107 | color: darken($font-color-primary, 15%) !important; 108 | } 109 | 110 | .w { 111 | &-25 { 112 | width: 25%; 113 | } 114 | 115 | &-50 { 116 | width: 50%; 117 | } 118 | 119 | &-75 { 120 | width: 75% 121 | } 122 | 123 | &-100 { 124 | width: 100%; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /app/renderer/scss/imports/_icons.scss: -------------------------------------------------------------------------------- 1 | .el-icon-refresh.animated { 2 | animation: rotating-clockwise 2s linear infinite; 3 | } 4 | 5 | @keyframes rotating-clockwise { 6 | 0% { 7 | transform: rotateZ(360deg); 8 | } 9 | 100% { 10 | transform: rotateZ(0deg); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/renderer/scss/imports/_main.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | display: flex; 4 | flex-direction: column; 5 | width: 100vw; 6 | max-height: 100vh; 7 | height: 100vh; 8 | 9 | * { 10 | font-family: BlinkMacSystemFont, "Rubik", sans-serif; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/renderer/scss/imports/_nav.scss: -------------------------------------------------------------------------------- 1 | a { 2 | color: darken($font-color-primary, 10%); 3 | 4 | &:active { 5 | color: darken($font-color-primary, 30%); 6 | } 7 | 8 | &:visited { 9 | color: $font-color-primary; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/renderer/scss/misc/tasks-style-misc.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Used for clickable projects and tasks 3 | */ 4 | .clickable { 5 | cursor: pointer; 6 | transition: $--all-transition; 7 | 8 | &:hover { 9 | color: $--color-primary-light-1; 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/app.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require: 0 */ 2 | const { app, shell, BrowserWindow } = require('electron'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const config = require('./base/config'); 6 | const appIcons = require('./utils/icons'); 7 | 8 | const { WEBCONTENTS_ALLOWED_PROTOCOLS } = require('./constants/url'); 9 | const userPreferences = require('./base/user-preferences'); 10 | 11 | /** 12 | * Object, containing Electron browser window 13 | * @type {Object} 14 | */ 15 | let window = {}; 16 | 17 | // Second instance protection 18 | if (!config.isDeveloperModeEnabled) { 19 | 20 | // If single instance lock successfully set — we are the first running instance 21 | if (app.requestSingleInstanceLock()) { 22 | 23 | app.on('second-instance', () => { 24 | 25 | // Reveal window then focus on it if it's exists 26 | if (!window) 27 | return; 28 | window.restore(); 29 | window.focus(); 30 | 31 | }); 32 | 33 | } else 34 | app.quit(); 35 | 36 | } 37 | 38 | // Register custom protocol for SSO 39 | app.setAsDefaultProtocolClient('cattr'); 40 | 41 | // Wait until Electron comes ready 42 | app.once('ready', async () => { 43 | 44 | // Load database 45 | await require('./models').init(); 46 | 47 | const router = require('./routes'); 48 | const osIntegration = require('./base/os-integration'); 49 | 50 | // Creates new window with specified parameters 51 | window = new BrowserWindow({ 52 | 53 | minWidth: 480, 54 | minHeight: 580, 55 | width: 480, 56 | height: 580, 57 | maxWidth: 640, 58 | maxHeight: 700, 59 | frame: true, 60 | show: false, 61 | center: true, 62 | maximizable: false, 63 | webPreferences: { 64 | nodeIntegration: true, 65 | enableRemoteModule: false, 66 | contextIsolation: false, 67 | nativeWindowOpen: true, 68 | }, 69 | icon: appIcons.DEFAULT, 70 | 71 | }); 72 | 73 | // Pass the window into OS integration module 74 | osIntegration.init(window); 75 | 76 | // Load components 77 | require('./components')(); 78 | 79 | /** 80 | * Loads a page into browser window + passing Sentry configuration 81 | * @param {String} page Page to load 82 | * @return {Boolean} Returns true if succeed 83 | */ 84 | const loadPage = page => { 85 | 86 | // Building absolute path to the requested page 87 | const pathToPage = path.resolve(__dirname, '../../build', page); 88 | 89 | // Is this page exists? 90 | if (!fs.existsSync(pathToPage)) 91 | throw new Error(`Requested page ${page} was not found`); 92 | 93 | // Encoding the Sentry configuration 94 | let sentryConfiguration = {}; 95 | 96 | // Cloning the original configuration 97 | Object.assign(sentryConfiguration, config.sentry); 98 | 99 | // Removing large but useless field which can potentialy broke Sentry on renderer 100 | delete sentryConfiguration.defaultIntegrations; 101 | 102 | // Apply user's decision on error reporting 103 | sentryConfiguration.enabled = config.sentry.enabled && userPreferences.get('errorReporting'); 104 | 105 | // Encode to JSON, then encode to URIEncoded 106 | sentryConfiguration = encodeURIComponent(JSON.stringify(sentryConfiguration)); 107 | 108 | // Load this page 109 | window.loadURL(`file://${pathToPage}?sentry=${sentryConfiguration}`); 110 | return true; 111 | 112 | }; 113 | 114 | // Intercept external links navigation 115 | window.webContents.on('will-navigate', (event, url) => { 116 | 117 | // Preventing default behavior, so the link wouldn't be loaded 118 | // inside the tracker's window 119 | event.preventDefault(); 120 | 121 | // Check is target protocol allowed 122 | const targetUrl = new URL(url); 123 | if (!WEBCONTENTS_ALLOWED_PROTOCOLS.has(targetUrl.protocol)) 124 | return; 125 | 126 | // Open link in external browser 127 | shell.openExternal(url); 128 | 129 | }); 130 | 131 | // Generate and inject CSP policy 132 | window.webContents.session.webRequest.onHeadersReceived((details, callback) => { 133 | 134 | // Build a CSP and apply the default policy 135 | let cspValue = "default-src 'self';"; 136 | 137 | // Apply assets policy 138 | cspValue += "style-src 'self' data: 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:;"; 139 | 140 | // Scripts: allow unsafe-eval in dev mode, otherwise Chrome DevTools wouldn't work 141 | if (config.isDeveloperModeEnabled) 142 | cspValue += "script-src 'self' 'unsafe-eval';"; 143 | else 144 | cspValue += "script-src 'self';"; 145 | 146 | // If Sentry is enabled, inject also a connect-src CSP allowing requests to Sentry host 147 | if (config.sentry.enabled && userPreferences.get('errorReporting')) { 148 | 149 | // Parse frontend's DSN to extract the host 150 | const frontendDsnUrl = new URL(config.sentry.dsnFrontend); 151 | 152 | // Inject connect-src policy allowing connections to self and Sentry hostname 153 | cspValue += `connect-src 'self' ${frontendDsnUrl.origin};`; 154 | 155 | } 156 | 157 | // Returning injection by callback 158 | callback({ 159 | responseHeaders: { 160 | ...details.responseHeaders, 161 | 'Content-Security-Policy': cspValue, 162 | }, 163 | }); 164 | 165 | }); 166 | 167 | // Re-render new page each time when it comes ready 168 | window.on('ready-to-show', () => { 169 | 170 | // Pass webContents to IPC 171 | router.setWebContents(window.webContents); 172 | 173 | // Show the main window 174 | window.show(); 175 | 176 | }); 177 | 178 | // Weird workaround for Linux (see https://github.com/electron/electron/issues/4544) 179 | window.setMenuBarVisibility(false); 180 | 181 | // Load frontend entry point 182 | loadPage('app.html'); 183 | 184 | // Open DevTools if we're in development mode 185 | if (config.isDeveloperModeEnabled && !process.env.DISABLE_DEVTOOLS) 186 | window.webContents.openDevTools('detached'); 187 | 188 | 189 | }); 190 | -------------------------------------------------------------------------------- /app/src/base/active-window.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require('events'); 2 | const activeWindow = require('active-win'); 3 | const Log = require('../utils/log'); 4 | 5 | const log = new Log('ActiveWindow'); 6 | 7 | /** 8 | * Polling interval 9 | * @type {number} Delay between polls in milliseconds 10 | */ 11 | const ACTIVE_WINDOW_POLLING_INTERVAL = 5000; 12 | 13 | class ActiveWindow extends EventEmitter { 14 | 15 | constructor() { 16 | 17 | super(); 18 | 19 | /** 20 | * Interval ID of polling timer 21 | * @type {Number|null} 22 | */ 23 | this.pollingTimerId = null; 24 | 25 | /** 26 | * Current application parameters 27 | * @type {Object} 28 | */ 29 | this.currentApplication = { 30 | executable: null, 31 | title: null, 32 | url: null, 33 | }; 34 | 35 | } 36 | 37 | /** 38 | * Timer status 39 | * @type {boolean} 40 | */ 41 | get active() { 42 | 43 | return this.pollingTimerId !== null; 44 | 45 | } 46 | 47 | /** 48 | * Starts active window polling 49 | * @returns {boolean} True if successfully started, False otherwise 50 | */ 51 | start() { 52 | 53 | if (this.active) 54 | return false; 55 | 56 | this.pollingTimerId = setInterval(async () => { 57 | 58 | try { 59 | 60 | const window = await activeWindow(); 61 | 62 | // Detect changes 63 | if ( 64 | window && window.owner 65 | && (window.owner.path !== this.currentApplication.executable 66 | || window.title !== this.currentApplication.title 67 | || window.url !== this.currentApplication.url) 68 | ) 69 | this.applyNewWindow(window); 70 | 71 | } catch (err) { 72 | 73 | log.error('Error occured during active window poll', err); 74 | 75 | } 76 | 77 | }, ACTIVE_WINDOW_POLLING_INTERVAL); 78 | return true; 79 | 80 | } 81 | 82 | /** 83 | * Stops the active window polling 84 | */ 85 | stop() { 86 | 87 | if (!this.pollingTimerId) 88 | return; 89 | 90 | clearInterval(this.pollingTimerId); 91 | this.pollingTimerId = null; 92 | 93 | } 94 | 95 | /** 96 | * Update current window 97 | * @private 98 | * @param {Object} window New window definition 99 | */ 100 | applyNewWindow(window) { 101 | 102 | this.currentApplication.executable = window.owner.path; 103 | this.currentApplication.title = window.title; 104 | this.currentApplication.url = window.url; 105 | this.emit('updated', this.currentApplication); 106 | 107 | } 108 | 109 | } 110 | 111 | module.exports = new ActiveWindow(); 112 | -------------------------------------------------------------------------------- /app/src/base/api.js: -------------------------------------------------------------------------------- 1 | const Cattr = require('@cattr/node'); 2 | const keychain = require('../utils/keychain'); 3 | 4 | const api = new Cattr(); 5 | 6 | api.tokenProvider = { 7 | 8 | get: keychain.getSavedToken, 9 | set: keychain.saveToken, 10 | 11 | }; 12 | 13 | api.credentialsProvider = { 14 | 15 | get: keychain.getSavedCredentials, 16 | set: keychain.saveCredentials, 17 | 18 | }; 19 | 20 | module.exports = api; 21 | -------------------------------------------------------------------------------- /app/src/base/config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { resolve } = require('path'); 3 | const { app } = require('electron'); 4 | const argv = require('minimist')(process.argv.slice(2)); 5 | const packageManifest = require('../../../package.json'); 6 | 7 | // Development mode rules 8 | const isDeveloperModeEnabled = ( 9 | 10 | // Specific environment variable 11 | process.env.AT_DEVMODE === 'meow' 12 | 13 | ); 14 | 15 | /** 16 | * Identifier of the package used in the filesystem and keychain 17 | * @type {String} 18 | */ 19 | const packageId = isDeveloperModeEnabled ? 'cattr-develop' : 'cattr'; 20 | 21 | /** 22 | * Version of this Cattr package 23 | * @type {String} 24 | */ 25 | const packageVersion = packageManifest.version; 26 | 27 | // Basic configuration 28 | const configuration = { 29 | 30 | packageId, 31 | packageVersion, 32 | 33 | // Application data directory 34 | appdata: isDeveloperModeEnabled ? app.getPath('userData').concat('-develop') : app.getPath('userData'), 35 | 36 | // Application codebase path 37 | apppath: app.getAppPath(), 38 | 39 | // Is we're currently in development mode? 40 | isDeveloperModeEnabled, 41 | 42 | }; 43 | 44 | // Sentry error handling 45 | configuration.sentry = { 46 | 47 | // Is Sentry enabled? 48 | enabled: !isDeveloperModeEnabled || process.env.AT_SENTRY === 'force', 49 | 50 | // Main application DSN 51 | dsn: 'https://b0ab7e30102244948431ecf5b1eb9c9a@sentry.amazingcat.net/15', 52 | 53 | // Frontend application DSN 54 | dsnFrontend: 'https://00bd1ee1db824310812252bb96e96945@sentry.amazingcat.net/14', 55 | 56 | // Setting the current release 57 | release: `cattr@${packageVersion}`, 58 | 59 | }; 60 | 61 | // Ensure that application data directory actually exists 62 | if (!fs.existsSync(configuration.appdata)) 63 | fs.mkdirSync(configuration.appdata); 64 | 65 | // Ensure that database directory actually exists 66 | if (!fs.existsSync(resolve(configuration.appdata, 'db'))) 67 | fs.mkdirSync(resolve(configuration.appdata, 'db')); 68 | 69 | // Database configuration 70 | configuration.localDB = { 71 | 72 | // Sequelize options 73 | opts: { 74 | 75 | // Set SQLite as dialect 76 | dialect: 'sqlite', 77 | 78 | // Set database path 79 | storage: resolve(configuration.appdata, 'db', 'main.db'), 80 | 81 | // Disable SQL queries logging by default, enable via flag 82 | logging: (argv['debug-sql-logging'] === 'true'), 83 | 84 | }, 85 | 86 | }; 87 | 88 | // Logger settings 89 | configuration.logger = { 90 | 91 | // Logs directory 92 | directory: argv['logs-directory'] || resolve(configuration.appdata, 'logs'), 93 | 94 | }; 95 | 96 | // Credentials storage 97 | configuration.credentialsStore = { 98 | 99 | // Service identifier 100 | service: `amazingcat/${packageId}`, 101 | 102 | }; 103 | 104 | // User Preferences file 105 | configuration.userPreferences = { 106 | 107 | file: `${configuration.appdata}/at-user-preferences.json`, 108 | 109 | }; 110 | 111 | // Usage statistics reporter 112 | configuration.usageStatistics = { 113 | 114 | /** 115 | * Report usage statistics 116 | * Notice that even if reporting is enabled here, priority is on User Preferences parameter. 117 | * In other words, even if stats is enabled here, user's decision in Settings is more important. 118 | */ 119 | enabled: true, 120 | 121 | /** 122 | * Base URL of statistics collector 123 | */ 124 | collectorUrl: 'https://stats.cattr.app', 125 | 126 | }; 127 | 128 | // New release available notification 129 | configuration.updateNotification = { 130 | 131 | /** 132 | * Enables update notification mechanism 133 | * Notice that user preference "DISABLE" is overriding this value 134 | * @type {Boolean} 135 | */ 136 | enabled: (!process.windowsStore && !process.mas), 137 | 138 | /** 139 | * Base URL to retrieve releases manifest 140 | * @type {String} 141 | */ 142 | manifestBaseUrl: 'https://git.amazingcat.net/api/v4/projects/353/releases/permalink/latest', 143 | 144 | /** 145 | * URL to downloads page 146 | * @type {String} 147 | */ 148 | downloadsPageUrl: 'https://cattr.app/desktop', 149 | 150 | }; 151 | 152 | // Export configuration 153 | module.exports = configuration; 154 | -------------------------------------------------------------------------------- /app/src/base/deferred-handler.js: -------------------------------------------------------------------------------- 1 | const IntervalsController = require('../controller/time-intervals'); 2 | const TimeIntervalModel = require('../models').db.models.Interval; 3 | const Log = require('../utils/log'); 4 | const OfflineMode = require('./offline-mode'); 5 | const TaskTracker = require('./task-tracker'); 6 | 7 | const log = new Log('DeferredHandler'); 8 | 9 | /** 10 | * If deferred intervals push procedure is already running, 11 | * we should lock this from more executions to avoid collisions 12 | * @type {Boolean} 13 | */ 14 | let threadLock = false; 15 | 16 | /** 17 | * Pushes deferred intervals 18 | * @return {Promise} True, if everything is pushed, error otherwise 19 | */ 20 | const deferredIntervalsPush = async () => { 21 | 22 | // Check thread lock 23 | if (threadLock) 24 | return; 25 | 26 | // Locking thread 27 | threadLock = true; 28 | 29 | // Getting all deferred intervals 30 | const deferredIntervals = await TimeIntervalModel.findAll({ where: { synced: false } }); 31 | 32 | // Skip sync routine if there are no deffered intervals 33 | if (deferredIntervals.length === 0) { 34 | 35 | threadLock = false; 36 | return; 37 | 38 | } 39 | 40 | // Catching all the deferred intervals errors 41 | try { 42 | 43 | // Iterating over deferred intervals 44 | // eslint-disable-next-line no-restricted-syntax 45 | for await (const rawInterval of deferredIntervals) { 46 | 47 | // Prepare interval structure 48 | /* eslint camelcase: 0 */ 49 | const preparedInterval = { 50 | 51 | _isDeferred: true, 52 | task_id: rawInterval.taskId, 53 | start_at: rawInterval.startAt, 54 | end_at: rawInterval.endAt, 55 | user_id: rawInterval.userId, 56 | activity_fill: rawInterval.systemActivity, 57 | 58 | }; 59 | 60 | if (rawInterval.mouseActivity) 61 | preparedInterval.mouse_fill = rawInterval.mouseActivity; 62 | 63 | if (rawInterval.keyboardActivity) 64 | preparedInterval.keyboard_fill = rawInterval.keyboardActivity; 65 | 66 | // Push deferred interval 67 | let res = null; 68 | 69 | if (rawInterval.screenshot) 70 | res = await IntervalsController.pushTimeInterval(preparedInterval); 71 | else 72 | res = await IntervalsController.pushTimeInterval(preparedInterval, rawInterval.screenshot); 73 | 74 | log.debug(`Deferred interval (${res.response.data.id}) has been pushed`); 75 | 76 | // Update interval status 77 | rawInterval.synced = true; 78 | rawInterval.remoteId = res.response.data.id; 79 | await rawInterval.save(); 80 | 81 | // Will trigger 'Not Synced Intervals' count 82 | TaskTracker.emit('interval-pushed', { 83 | deferred: true 84 | }); 85 | 86 | // Remove old intervals from queue 87 | await IntervalsController.reduceSyncedIntervalQueue(); 88 | 89 | } 90 | 91 | // That's all 92 | log.debug('Deferred screenshots queue is empty, nice work'); 93 | 94 | } catch (error) { 95 | 96 | // Handle connectivity errors 97 | if (error.request && !error.response) { 98 | 99 | // Trigger offline mode then exit 100 | OfflineMode.trigger(); 101 | 102 | // Unlocking thread 103 | threadLock = false; 104 | 105 | // Ignore this error 106 | return; 107 | 108 | } 109 | 110 | // Log other errors 111 | log.warning(`Error occured during deferred intervals push: ${error}`); 112 | throw error; 113 | 114 | } 115 | 116 | // Unlocking thread 117 | threadLock = false; 118 | 119 | }; 120 | 121 | // Push deferred intervals if connection is restored 122 | OfflineMode.on('connection-restored', deferredIntervalsPush); 123 | OfflineMode.once('connection-ok', deferredIntervalsPush); 124 | -------------------------------------------------------------------------------- /app/src/base/offline-mode.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const auth = require('./authentication'); 3 | const heartbeatMonitor = require('../utils/heartbeat-monitor'); 4 | const Log = require('../utils/log'); 5 | 6 | const log = new Log('OfflineMode'); 7 | 8 | class OfflineModeHandler extends EventEmitter { 9 | 10 | /** 11 | * Returns status of offline mode (true if enabled) 12 | * @return {Boolean} Is offline mode enabled? 13 | */ 14 | get enabled() { 15 | 16 | return this._isEnabled; 17 | 18 | } 19 | 20 | /** 21 | * Builds the class 22 | */ 23 | constructor() { 24 | 25 | super(); 26 | 27 | /** 28 | * Status of the offline mode 29 | * @type {Boolean} 30 | */ 31 | this._isEnabled = false; 32 | 33 | /** 34 | * Identifier of the server ping timer 35 | * @type {Number|null} 36 | */ 37 | this._pingTimer = null; 38 | 39 | /** 40 | * Check server connectivity each 30 sec 41 | * @type {Number} 42 | * @constant 43 | */ 44 | this._PING_TIMER_INTERVAL = 30 * 1000; 45 | 46 | /** 47 | * Is tracker active right now? 48 | * @type {Boolean} 49 | */ 50 | this.trackingActive = false; 51 | 52 | } 53 | 54 | /** 55 | * Set current tracker status 56 | * @param {Boolean} status 57 | */ 58 | setTrackerStatus(status) { 59 | 60 | this.trackingActive = Boolean(status); 61 | 62 | } 63 | 64 | /** 65 | * Triggers offline mode on 66 | */ 67 | trigger() { 68 | 69 | // Ignore repeats 70 | if (this._isEnabled) 71 | return; 72 | 73 | // Turn offline mode on 74 | log.debug(`Offline mode is triggered, checking connectivity each ${this._PING_TIMER_INTERVAL} ms`); 75 | this._isEnabled = true; 76 | 77 | // Notify that offline mode is enabled 78 | this.emit('offline'); 79 | 80 | // Stop heartbeating to backend when tracker is paused 81 | heartbeatMonitor.stop(); 82 | 83 | // Set connectivity check timer 84 | this._pingTimer = setInterval(async () => { 85 | 86 | // Check connectivity 87 | const serverStatus = await auth.ping(); 88 | 89 | // Connection is not restored 90 | if (!serverStatus) 91 | return log.debug('Connection still not restored'); 92 | 93 | // Call offline mode restore routine 94 | return this.restore(); 95 | 96 | }, this._PING_TIMER_INTERVAL); 97 | 98 | } 99 | 100 | 101 | /** 102 | * Cancels offline mode 103 | */ 104 | async restore() { 105 | 106 | // Notify about connection OK status 107 | this.emit('connection-ok'); 108 | 109 | if (!this._isEnabled) 110 | return; 111 | 112 | // Connection restored 113 | log.debug('Connection restored'); 114 | 115 | // Reset timer 116 | clearInterval(this._pingTimer); 117 | this._pingTimer = null; 118 | 119 | // Reset state 120 | this._isEnabled = false; 121 | 122 | // Emit reconnection event 123 | this.emit('connection-restored'); 124 | 125 | } 126 | 127 | /** 128 | * Disarm offline mode if server is available 129 | */ 130 | async restoreWithCheck() { 131 | 132 | const connectivityEstablished = await auth.ping(); 133 | 134 | // Notify about connection OK status 135 | if (connectivityEstablished) 136 | this.emit('connection-ok'); 137 | 138 | // Restore OfflineMode status if we're offline, 139 | // but connection is established during the latest ping 140 | if (this._isEnabled && connectivityEstablished) 141 | this.restore(); 142 | 143 | // If the tracking is enabled and the connection is restored, we start the heartbeat again 144 | if (this.heartbeatEnabled && connectivityEstablished) 145 | heartbeatMonitor.start(); 146 | 147 | } 148 | 149 | } 150 | 151 | /** 152 | * Exports single instance of OfflineModeHander 153 | * @type {OfflineModeHandler} 154 | */ 155 | module.exports = new OfflineModeHandler(); 156 | -------------------------------------------------------------------------------- /app/src/base/translation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Translations support 3 | */ 4 | 5 | const { EventEmitter } = require('events'); 6 | const Log = require('../utils/log'); 7 | const translationsLoader = require('../utils/translations'); 8 | const userPreferences = require('./user-preferences'); 9 | 10 | const log = new Log('Translation'); 11 | 12 | /** 13 | * Translations 14 | */ 15 | class Translation extends EventEmitter { 16 | 17 | /** 18 | * Initialises translations for i18next 19 | */ 20 | constructor() { 21 | 22 | super(); 23 | 24 | /** 25 | * Available translation resources 26 | * @type {Object} 27 | */ 28 | this._resources = translationsLoader.resources; 29 | 30 | /** 31 | * Available languages 32 | * @type {Object} 33 | */ 34 | this._languages = translationsLoader.languages; 35 | 36 | /** 37 | * Default fallback language 38 | * @type {String} 39 | */ 40 | this.DEFAULT_FALLBACK_LANG = 'en'; 41 | 42 | /** 43 | * Currently selected language 44 | * @type {String} 45 | */ 46 | this._currentLanguage = userPreferences.get('language'); 47 | 48 | // Check availability of the selected language 49 | if (typeof this._resources[this._currentLanguage] === 'undefined') { 50 | 51 | // Check availability for the fallback language 52 | if (typeof this._resources[this.DEFAULT_FALLBACK_LANG] === 'undefined') { 53 | 54 | // Fallback language is not available, select first available lang 55 | [this._currentLanguage] = Object.keys(this._resources); 56 | 57 | // Leave a message 58 | log.error('TRS01', `Falling back to first available language, because selected & fallback are not available: ${this._currentLanguage}`); 59 | 60 | } else { 61 | 62 | // Log entry 63 | log.error('TRS02', `Falling back to fallback language because selected language is not available: ${this._currentLanguage}`); 64 | 65 | // Select fallback language 66 | this._currentLanguage = this.DEFAULT_FALLBACK_LANG; 67 | 68 | } 69 | 70 | } 71 | 72 | // Subscribe user preferences "commited" event 73 | userPreferences.on('commited', () => { 74 | 75 | // Update current language if user preferences changed & saved 76 | this.updateLanguage(); 77 | 78 | }); 79 | 80 | } 81 | 82 | /** 83 | * Get translation by the key 84 | * @param {String} key Translation Resource key 85 | * @return {String} Translation 86 | */ 87 | translate(key) { 88 | 89 | if (Object.prototype.hasOwnProperty.call(this._resources[this._currentLanguage].translation, key)) 90 | return this._resources[this._currentLanguage].translation[key]; 91 | return key; 92 | 93 | } 94 | 95 | /** 96 | * Returns map of available languages 97 | * @return {Object} Map between full language title and code 98 | */ 99 | getLanguages() { 100 | 101 | return this._languages; 102 | 103 | } 104 | 105 | /** 106 | * Return configuration for i18next 107 | * @return {Object} i18next configuration 108 | */ 109 | getConfiguration() { 110 | 111 | return { lng: this._currentLanguage, resources: this._resources }; 112 | 113 | } 114 | 115 | /** 116 | * Updates current language 117 | * @param {String|null} language Language to set 118 | * If null / empty argument passed, language will be pulled from user preferences 119 | */ 120 | updateLanguage(language = null) { 121 | 122 | // Get new language from args or userPreferences 123 | const newLanguage = language || userPreferences.get('language'); 124 | if (this._currentLanguage !== newLanguage) { 125 | 126 | // Update current language 127 | this._currentLanguage = newLanguage; 128 | 129 | // Emit "lanuage-changed" event 130 | this.emit('language-changed', { newLanguage, translation: this }); 131 | 132 | } 133 | 134 | } 135 | 136 | } 137 | 138 | // Initialize Translation module and export ready-to-use instance 139 | module.exports = new Translation(); 140 | -------------------------------------------------------------------------------- /app/src/base/update.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require('events'); 2 | const axios = require('axios'); 3 | const semver = require('semver'); 4 | const Log = require('../utils/log'); 5 | const configuration = require('./config'); 6 | const userPreferences = require('./user-preferences'); 7 | 8 | const log = new Log('Update'); 9 | 10 | /** 11 | * @typedef {Object} UpdateData 12 | * @property {String} version Update version 13 | * @property {String} current Current installed version 14 | * @property {String} downloadsPageUrl URL of downloads page 15 | */ 16 | 17 | /** 18 | * Self-update actions 19 | */ 20 | class Update extends EventEmitter { 21 | 22 | /** 23 | * Retrieve update data from DL server 24 | * @async 25 | * @returns {Promise.} Object with update info if newer version is available, null otherwise 26 | */ 27 | async retrieveUpdate() { 28 | 29 | // Check is update notifier functionality is enabled in App configuration 30 | if (!configuration.updateNotification.enabled) 31 | return null; 32 | 33 | // Check is update notification is not disabled by user 34 | if (!userPreferences.get('updateNotification')) 35 | return null; 36 | 37 | // Obtain platform 38 | let platform = null; 39 | switch (process.platform) { 40 | 41 | case 'win32': platform = 'windows'; break; 42 | case 'darwin': platform = 'mac'; break; 43 | case 'linux': platform = 'linux'; break; 44 | default: 45 | log.debug(`unsupported architecture: ${process.platform}`); 46 | return null; 47 | 48 | } 49 | 50 | try { 51 | 52 | // Retrieve manifest, then check its structure 53 | const manifestReq = await axios.get(configuration.updateNotification.manifestBaseUrl); 54 | if (manifestReq.status !== 200 || !manifestReq.data?.name) 55 | return null; 56 | 57 | const manifestVersion = manifestReq.data.name.replace('v', ''); 58 | 59 | // Filer incorrect, older, or current version 60 | if (configuration.packageVersion === 'dev' || !semver.valid(manifestVersion) || semver.lte(manifestVersion, configuration.packageVersion)) 61 | return null; 62 | 63 | const updateData = { 64 | version: manifestVersion, 65 | current: configuration.packageVersion, 66 | downloadsPageUrl: configuration.updateNotification.downloadsPageUrl, 67 | }; 68 | 69 | this.emit('update-available', updateData); 70 | return updateData; 71 | 72 | } catch (err) { 73 | 74 | log.error('Error occured during update manifest retrival', err); 75 | return null; 76 | 77 | } 78 | 79 | } 80 | 81 | } 82 | 83 | module.exports = new Update(); 84 | -------------------------------------------------------------------------------- /app/src/components/application-menu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Application menu 3 | */ 4 | 5 | const { Menu } = require('electron'); 6 | const osIntegration = require('../base/os-integration'); 7 | 8 | /** 9 | * Template buffer 10 | * @type {Array} 11 | */ 12 | const applicationMenuTemplate = []; 13 | 14 | /** 15 | * Application default things 16 | */ 17 | applicationMenuTemplate.push({ 18 | 19 | label: 'Application', 20 | submenu: [ 21 | 22 | { label: 'About', selector: 'orderFrontStandardAboutPanel:' }, 23 | { type: 'separator' }, 24 | { label: 'Quit', accelerator: 'Command+Q', click: osIntegration._windowClose }, 25 | 26 | ], 27 | 28 | }); 29 | 30 | /** 31 | * Edit tab (implements clipboard functions) 32 | */ 33 | applicationMenuTemplate.push({ 34 | 35 | label: 'Edit', 36 | submenu: [ 37 | 38 | { label: 'Undo', accelerator: 'CmdOrCtrl+Z', selector: 'undo:' }, 39 | { label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', selector: 'redo:' }, 40 | { type: 'separator' }, 41 | { label: 'Cut', accelerator: 'CmdOrCtrl+X', selector: 'cut:' }, 42 | { label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' }, 43 | { label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' }, 44 | { label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' }, 45 | 46 | ], 47 | 48 | }); 49 | 50 | // Build menu after osIntegration is initialises 51 | osIntegration.on('window-is-set', () => Menu.setApplicationMenu(Menu.buildFromTemplate(applicationMenuTemplate))); 52 | -------------------------------------------------------------------------------- /app/src/components/disable-production-hotkeys.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Disables Chrome hotkeys in production 3 | */ 4 | const hotkey = require('electron-hotkey'); 5 | 6 | hotkey.register('local', 'CommandOrControl+r', 'reload'); 7 | hotkey.register('local', 'CommandOrControl+Shift+r', 'full-reload'); 8 | hotkey.register('local', 'CommandOrControl+Shift+i', 'browser-menu'); 9 | -------------------------------------------------------------------------------- /app/src/components/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | /** 3 | * Loads application components 4 | */ 5 | module.exports = () => { 6 | 7 | require('./application-menu.js'); 8 | require('./disable-production-hotkeys.js'); 9 | require('./screen-notie.js'); 10 | require('./tray.js'); 11 | require('./relaunch-on-logout.js'); 12 | require('./power-manager.js'); 13 | require('./os-inactivity-handler.js'); 14 | require('./usage-statistic'); 15 | require('./log-rotate'); 16 | 17 | }; 18 | -------------------------------------------------------------------------------- /app/src/components/log-rotate.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { readdirSync: readdir, promises: { unlink } } = require('fs'); 3 | const Log = require('../utils/log'); 4 | const config = require('../base/config'); 5 | const { KEEP_LAST_X_ENTRIES } = require('../constants/log-rotate'); 6 | 7 | const log = new Log('LogRotate'); 8 | 9 | // Get list of log files to remove 10 | const filesToRemove = readdir(config.logger.directory) 11 | 12 | // Filter logfiles by stupidly simple signature 13 | .filter(filename => /^(at).*(\.log)$/.test(filename)) 14 | 15 | // Sort it ASC (oldest first) 16 | .sort() 17 | 18 | // Reverse to DESC 19 | .reverse() 20 | 21 | // Select all log files older than Nth element 22 | .slice(KEEP_LAST_X_ENTRIES); 23 | 24 | // Remove obsolete logs 25 | if (filesToRemove.length) { 26 | 27 | log.debug(`Removing ${filesToRemove.length} log files`); 28 | Promise 29 | .all(filesToRemove.map(async f => unlink(path.resolve(config.logger.directory, f)))) 30 | .then(() => log.debug('Removed')) 31 | .catch(err => log.error('Error occured during log rotation', err)); 32 | 33 | } 34 | -------------------------------------------------------------------------------- /app/src/components/os-inactivity-handler.js: -------------------------------------------------------------------------------- 1 | const { app, Notification } = require('electron'); 2 | const tracker = require('../base/task-tracker'); 3 | const osIntegration = require('../base/os-integration'); 4 | const translation = require('../base/translation'); 5 | 6 | class OsInactivityHandler { 7 | 8 | constructor() { 9 | 10 | this._inactivityResultAccepted = false; 11 | 12 | this._macBounceId = null; 13 | 14 | this._macInactivityNotify = null; 15 | 16 | tracker.on('activity-proof-request', () => { 17 | 18 | this._inactivityResultAccepted = false; 19 | 20 | osIntegration.windowFocus(); 21 | 22 | osIntegration.window.flashFrame(true); 23 | 24 | if (process.platform === 'darwin') { 25 | 26 | if (!this._macInactivityNotify) { 27 | 28 | this._macInactivityNotify = new Notification({ 29 | title: translation.translate('Cattr'), 30 | body: translation.translate('Are you still working?'), 31 | silent: false, 32 | hasReply: false, 33 | closeButtonText: translation.translate('No'), 34 | actions: [ 35 | { 36 | text: translation.translate('Yes'), 37 | type: 'button', 38 | }, 39 | ], 40 | }); 41 | 42 | this._macInactivityNotify.on('close', () => { 43 | 44 | if (!this._inactivityResultAccepted) { 45 | 46 | this._inactivityResultAccepted = true; 47 | tracker.emit('activity-proof-result', false); 48 | 49 | } 50 | 51 | }); 52 | this._macInactivityNotify.on('action', (action, index) => { 53 | 54 | if (index === 0 && !this._inactivityResultAccepted) { 55 | 56 | this._inactivityResultAccepted = true; 57 | tracker.emit('activity-proof-result', true); 58 | 59 | } 60 | 61 | }); 62 | 63 | this._macInactivityNotify.show(); 64 | 65 | } 66 | 67 | if (!this._macBounceId && app.dock && app.dock.bounce) 68 | this._macBounceId = app.dock.bounce('critical'); 69 | 70 | } 71 | 72 | }); 73 | 74 | tracker.on('activity-proof-result', result => { 75 | 76 | this._inactivityResultAccepted = true; 77 | 78 | osIntegration.window.flashFrame(false); 79 | 80 | if (process.platform === 'darwin') { 81 | 82 | if (this._macInactivityNotify) { 83 | 84 | this._macInactivityNotify.close(); 85 | this._macInactivityNotify = null; 86 | 87 | } 88 | 89 | if (!result) { 90 | 91 | const notify = new Notification({ 92 | title: translation.translate('Cattr'), 93 | body: translation.translate('Tracker was stopped due to inactivity!'), 94 | silent: false, 95 | hasReply: false, 96 | closeButtonText: translation.translate('Close'), 97 | }); 98 | notify.show(); 99 | 100 | } 101 | 102 | if (this._macBounceId && app.dock && app.dock.cancelBounce) { 103 | 104 | app.dock.cancelBounce(this._macBounceId); 105 | this._macBounceId = null; 106 | 107 | } 108 | 109 | } 110 | 111 | }); 112 | 113 | } 114 | 115 | } 116 | 117 | module.exports = new OsInactivityHandler(); 118 | -------------------------------------------------------------------------------- /app/src/components/power-manager.js: -------------------------------------------------------------------------------- 1 | const { powerMonitor, powerSaveBlocker } = require('electron'); 2 | const tracker = require('../base/task-tracker'); 3 | const osIntegration = require('../base/os-integration'); 4 | const Logger = require('../utils/log'); 5 | 6 | const log = new Logger('PowerManager'); 7 | 8 | class PowerManager { 9 | 10 | constructor() { 11 | 12 | this._suspendDetected = false; 13 | this._powerSaveBlockedId = -1; 14 | 15 | powerMonitor.on('suspend', () => { 16 | 17 | log.debug('System going to sleep.'); 18 | this.pauseTracking(); 19 | 20 | }); 21 | powerMonitor.on('resume', () => { 22 | 23 | log.debug('System resumed from sleep state.'); 24 | this.resumeTracking(); 25 | 26 | }); 27 | 28 | powerMonitor.on('lock-screen', () => { 29 | 30 | log.debug('System locked.'); 31 | this.pauseTracking(); 32 | 33 | }); 34 | powerMonitor.on('unlock-screen', () => { 35 | 36 | log.debug('System unlocked.'); 37 | this.resumeTracking(); 38 | 39 | }); 40 | 41 | powerMonitor.on('shutdown', () => osIntegration.gracefullExit()); 42 | 43 | tracker.on('started', () => { 44 | 45 | this._powerSaveBlockedId = powerSaveBlocker.start('prevent-display-sleep'); 46 | if (powerSaveBlocker.isStarted(this._powerSaveBlockedId)) 47 | log.debug('Prevent display sleep while tracking!'); 48 | else 49 | log.warning('Can\'t setup Power Save Blocker!'); 50 | 51 | }); 52 | tracker.on('stopped', () => { 53 | 54 | if (this._powerSaveBlockedId > -1 && powerSaveBlocker.isStarted(this._powerSaveBlockedId)) { 55 | 56 | log.debug('Now display can sleep!'); 57 | powerSaveBlocker.stop(this._powerSaveBlockedId); 58 | 59 | } 60 | 61 | }); 62 | 63 | log.debug('Loaded'); 64 | 65 | } 66 | 67 | pauseTracking() { 68 | 69 | if (tracker.active && !this._suspendDetected) { 70 | 71 | this._suspendDetected = true; 72 | tracker.pauseTicker(); 73 | log.debug('Tracker paused.'); 74 | 75 | } 76 | 77 | } 78 | 79 | resumeTracking() { 80 | 81 | if (this._suspendDetected) { 82 | 83 | this._suspendDetected = false; 84 | tracker.resumeTicker(); 85 | log.debug('Tracker resumed.'); 86 | 87 | } 88 | 89 | } 90 | 91 | } 92 | 93 | module.exports = new PowerManager(); 94 | -------------------------------------------------------------------------------- /app/src/components/relaunch-on-logout.js: -------------------------------------------------------------------------------- 1 | const { app } = require('electron'); 2 | const osIntegration = require('../base/os-integration'); 3 | const authentication = require('../base/authentication'); 4 | 5 | // Handle logged-out event 6 | authentication.events.on('logged-out', () => { 7 | 8 | // Set relaunch flag 9 | app.relaunch(); 10 | 11 | // Close application gracefully 12 | osIntegration.gracefullExit(); 13 | 14 | }); 15 | -------------------------------------------------------------------------------- /app/src/components/screen-notie.js: -------------------------------------------------------------------------------- 1 | const notie = require('../utils/notifier'); 2 | const tracker = require('../base/task-tracker'); 3 | const Logger = require('../utils/log'); 4 | const osIntegration = require('../base/os-integration'); 5 | const userPreferences = require('../base/user-preferences'); 6 | const EMPTY_IMAGE = require('../constants/empty-screenshot'); 7 | 8 | const log = new Logger('Screen-Notifier'); 9 | 10 | tracker.on('interval-pushed', data => { 11 | 12 | // Do not show notie is application is about to quit or application window is not exists 13 | if ( 14 | osIntegration.isApplicationClosingNow 15 | || !osIntegration.window 16 | || !userPreferences.get('showScreenshotNotification') 17 | || data.deferred === true 18 | ) 19 | return; 20 | 21 | // Check is screenshot exists 22 | if (data.screenshot) { 23 | notie.screenshotNotification(data.screenshot, data.interval); 24 | } else { 25 | notie.screenshotNotification(EMPTY_IMAGE, data.interval); 26 | } 27 | 28 | }); 29 | 30 | log.debug('Loaded'); 31 | -------------------------------------------------------------------------------- /app/src/components/usage-statistic.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require('events'); 2 | const os = require('os'); 3 | const axios = require('axios'); 4 | const Company = require('../base/api').company; 5 | const configuration = require('../base/config'); 6 | const authentication = require('../base/authentication'); 7 | const userPreferences = require('../base/user-preferences'); 8 | 9 | /** 10 | * Usage statistics emitter 11 | * @class 12 | */ 13 | class UsageStatistics extends EventEmitter { 14 | 15 | constructor() { 16 | 17 | super(); 18 | 19 | // Waiting until backend connection establishment 20 | authentication.events.once('user-fetched', () => UsageStatistics.reportStats()); 21 | 22 | } 23 | 24 | /** 25 | * Report usage statistics 26 | * @async 27 | * @returns {Promise.} True, if usage report is sent, false otherwise 28 | */ 29 | static async reportStats() { 30 | 31 | // Skip usage report if usage statistics share is disabled 32 | if (!configuration.usageStatistics.enabled || !userPreferences.get('usageStatistics')) 33 | return false; 34 | 35 | try { 36 | 37 | // Get backend installation ID 38 | const companyAbout = await Company.about(); 39 | if (!companyAbout.app || !companyAbout.app.instance_id) 40 | return false; 41 | 42 | const usageReport = { 43 | instanceId: companyAbout.app.instance_id, 44 | osVersion: os.release(), 45 | osPlatform: os.platform(), 46 | appType: 'desktop', 47 | appVersion: configuration.packageVersion, 48 | }; 49 | 50 | await axios.post(`${configuration.usageStatistics.collectorUrl}/v2/usage-report`, usageReport); 51 | return true; 52 | 53 | } catch (_) { 54 | 55 | return false; 56 | 57 | } 58 | 59 | } 60 | 61 | } 62 | 63 | module.exports = new UsageStatistics(); 64 | -------------------------------------------------------------------------------- /app/src/constants/ScreenshotsState.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | ANY: -1, 4 | FORBIDDEN: 0, 5 | REQUIRED: 1, 6 | OPTIONAL: 2, 7 | 8 | }; 9 | -------------------------------------------------------------------------------- /app/src/constants/empty-screenshot.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable-next-line max-len */ 2 | module.exports = Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAQEBAQEBAQEBAQGBgUGBggHBwcHCAwJCQkJCQwTDA4MDA4MExEUEA8QFBEeFxUVFx4iHRsdIiolJSo0MjRERFwBBAQEBAQEBAQEBAYGBQYGCAcHBwcIDAkJCQkJDBMMDgwMDgwTERQQDxAUER4XFRUXHiIdGx0iKiUlKjQyNEREXP/CABEIAKkBLAMBIgACEQEDEQH/xAAcAAEAAwEBAQEBAAAAAAAAAAAABgcIBQMCAQT/2gAIAQEAAAAAuwAAAAAAAAAAAAABCPCfAAAAAAoOh9T2acXyPfsAAAAHzmapdU2ZFsXD91lY4AAAB55VrjVNlw3yKJh+vv6frqfoAAAj2SY49dU2YOTi7mEq1t2gAADOFaaW9KjqbVFmiPx945jt7QgAABl/naxfOZ6l1RZgGQpTpQAAAy/ztYlS5f8AbVFmAyFKdKAAAGX+drExtJvWqdUWYGQpTpQAAAy/ztYmHbuvHMlU6oswZClOlAAADL/O1iZR4mwPfMNV6oswyFKdKAAAGX+drEiuPulOYDyGqLNZClOlAAADL/O1iI9R8blV3Z/qfVFm5nk15AAAGZIzsD9AfOZam1PZwAAAVxk7+n7APn+P11NZ4AAAQ+EfIAQCstXWOAAAAAHznWb2qAAAAAAAAAAAAAAAAAAAAH//xAAZAQEAAwEBAAAAAAAAAAAAAAAAAgMEBQH/2gAIAQIQAAAAAAAA2ZY+ngAFm7BsS57wAN6WCN+ieGsAdDn3bedFpjQAOhz9Xt2COmNAA6HPt1wu518aAB0OesshsxxgANN4e+8+IAAT8iAAAD//xAAYAQEAAwEAAAAAAAAAAAAAAAAAAQMEAv/aAAgBAxAAAAAAAADNomEgAOMuvM52SADI52TVTxr7AGPZXk3Som4AY9mdVsmibgBj2V5+q9tM3ADHsccd5dPXQAoqCGyQAA5mQAAAf//EAC4QAAEEAgECBQQBBAMAAAAAAAMCBAUGAQcANlURFRcgQBASFjUTIlBWYDAyQ//aAAgBAQABDAD/AFa4XqMp6W6HAVuHdR2FEWwi2iBqaPv7JuKsvnBm1laoyUDdwdocLlsVYjUC/gs4Ex8gpIpf6nslebFWBzPRwi/ldX/ySK5+V1f/ACSK5+V1f/JIrjawwDwyG7Scjzm+WtCCIUhacKRsXXaoVZZuFFlUY3cHaHE5bFUI1Av4LOBMfIKQKX5djPm9UnTR2V4c+xKlJVhSc5wrW1v/ACWIw2eF8ZP5ZcCyImD/AGZFsGpxcQ5XJQEg1KxbuDtDictiqEagX8FnAmPkFIFL5xjOM4zjxwfXtMcFWYkADC/Tak9hHz02pPYR89NqT2EfNlUtNZkBvY4WUxVcnndbl2ks0znOWdprz1q3dimWaUfkED3thxM7CLzhKJlipWM4VjCk5xnHxrPYmdXiTyjz+rlhtk3ZXCzSTxeRfRu4O0OJy2KoRqBfwWcCY+QUgUv7J2GZz8U7inyfEUzEvIKTdxb5H2m+tauU5V3CFsXSltq/OsrHFNpVjnP8fxd1vyLl4mL8c/xVWBVZZ1jD4L/EgWq6QgaULillV6XUbsuebC1xmCwuYgxrXGN3B2hxOWxVCNQL+CzgTHyCkil/ZN1Wv2LI1TEYNwv0uo3Zc89LqN2XPD6ppJQrGOMIBdhhywE1Iw5V/erST8uHE5F5VnIvi7m6ra81P1ox+q0pIlQyJwpGxddqhVFm4QWVRjdwdocTpqVYjUC/gs4Ex8gpApf37P65neaU/fyvxtzdVtean60Y+za1tVCxqIVivwe8buDtDhctjKEagX8FnAmPkFIFL+7Z/XM7zSn7+V+NubqtrzU/WjH2X+SXKW6bMpXinU1bbzUy6kHwUlbbE12uCWSZhhKXFt3B2hwumplCNQL+CzgTHyCkCl/bs/rmd5pT9/K/G3N1W15qfrRj7LENQrBOCX/20iYeWc+D/wBFoQVCxFQlaNia7XBLLMww1Li27g7Q4XTUyhGoF/BZwJYP1IFL+zZ/XM7zSn7+V+NubqtrzU/WjH2bWhFxdoO8SjwbUa0Zqk6J6TClM2jts/bBeMjoM3WhBULGRCVo2Pr/APH1rmolPjFt3B2hxOmpVCPQL+CzgTHyCkCl/rs/rmd5pT9/K/G3N1W15qfrRj7LhV21rhyx5c4QeUi30M+NHyTdQXEFa56trzmJkFiHnc1ryP7MNY3CpuyzdiKksu/IfA25zpMsIVrS3cHaHE6amWI1Av4LOBMfIKSKX+mz+uZ3mlP38r8bc3VbXmp+tGPtsNWhrO2w3lWv3KltLy4FqXDyIHIsapu2V/b5aLGIfSz8q0LnJMQBQ1bhYFiqPjWKEB2JrpcGss1CiyuLbuDtDictirEegbABZwJj5BSBS/Np1WZzY3M01YmctNQ1iVjCyMzJNSNkfF3QFaLMwNnH9GuJNpFW6McvipEDGcZxjOM+OP8AgWhBEKGROFI2JrpcIss1CCyqMbuDtTictirEagbABZgpjpFSBS/x9lVItnhxlYo+6RIhYlrERCkLQ/fCQlAnp0I8zku4OeeZyXcHPPM5LuDnnmcl3BzzzOS7g555nJdwc88zku4OeeZyXcHPPM5LuDnipGQWlSFvnGU8Ac7U4nLYqhG1/sAFmCmOkVIFL/HsFFrdkXk79l9jpWlILxzlEs/wn0ThO8PueicJ3h9z0ThO8PueicJ3h9z0ThO8PueicJ3h9z0ThO8PueicJ3h9z0ThO8PueicJ3h9z0ThO8PuXfWjmsgTJRpSPI8BztTCctiqEbXd2za2JW71OEyX9jINBULEVCVot+pZBD1buqhSZrrSjvauN6/lVIw9/33//xAA9EAACAQICBAsHAgQHAAAAAAABAgMAEQQhMWFxsxITFSBAQUJRc7HRECIyUnSB0gViMFBgchRDU5GywsP/2gAIAQEADT8A/pacFo4IyF9wdpidApQWEErA8NR1o3X/ACWLDiDEqMzGFYsH2HhVE4dHQ2ZWGgg1EmY0LOo7aa+8cxDZkkxUSsDrBNfWQ/lX1kP5V9ZD+VNkscWJjdjsCnpjAhlIuCD1GmN5ohmcMT/51EwdHQ2ZWGgg1EmY0LOo7aa+8excKxBT4lXtkbF5oIIINiDWCCpNfTKnZk9emFSHD24JU6b36qlf38Mk6NJA57gDcpUTB0dDZlYaCDUSZjQs6jtpr7xRpzc8BnRfsqEAV4sv5V4sv5V4sv5Vi8kFyeKlGlCT/uKja0iXsJI2+JDU0auFknRHW40MCciK+pj9aPUMRGT50cwR0cWSGIGxllOhRV7ph0JWFBqX2xMHR0NmVhoINRJmNCzqO2mvvHNnSwYaUcZq66wagfgk9TDqZdTDMcy/v4WQkxONnZOsVKCGQ/FG40o2sdGhwhxFv3yuV8kqZmMknWqIpZrUBYu+JmDNt4LAV9TP+df5sVy7YfXc5lKiYOjobMrDQQaiTMaFnUdtNfeObGLK92RwO7hIQbV9TP8AnX1M/wCdMCBJHiJSy6wHZhWGl4IfRwlYBlb7g00ceJUdzKeA3RuTYd49cRiN2fawIZSLgg6QRTG80K5nDn8KicPHIhsysNBBqJMxoWdR20194/gXw24SuTzvF6NybDvHriMRuzzMejcNhpjw+g/d9A9kTB0kQ2ZWGgg1EmY0LOo7aa+8c++G3CVyed4vRuTYd49cRiN2eZFiGw0Y6gsHuZbSL1+nopEbgFWlkuFuDpAAJp2vJGMzhifNKiYOjobMrDQQaiTNdCzqO2mvvHOvhtwlcnneL0bk2HePXEYjdnmL+oYlTtEhpZoHP9rKQKdSrKwuCDkQRTteWIZnDE+aVEwdHQ2ZWGgg1EmY0LOo7aa+8c2+G3CVyed4vRuTYd49cRiN2eZ+oqJ0P7xk4qZeJxSjTxZPxDWpqVQySIbqwp1KsrC4IORBFSOA8XXh3bzQ1E4dHQ2ZWGgg1EmY0LOo7aa+8cy+G3CVyed4vRuTYd49cRiN2eYh4zDTfJIP+p0GojZlbzB6weo0xu0LAPE21Wr5+Kkv/wA6U3SPJY0/tRbCok4chRSwRL24TW0ComDo6GzKw0EGokzGhZ1HbTX3j23w24SuTzvF6NybDvHriMRuzzVB4uZPdljv8rV1JNeKQeYNfN/iIreddceGvI5+7AAU4tLwhw2l8Qn4qY3liGZwxPmlROHR0NmVhmCCKiXMaFnUdtNfePZjEiPDhQvwHRBGVYLsqaFYIY5VKuwvwixU5gdGf9OQA61kenEsJkY2VTIhVSaP8FgVZWFwQciCDTG8sQzOGJ80qJw6OhsysMwQRUS7FnUdtNfeOkYEs8K/6iN8abTYEUjFWVhYgjIgigMlWRgBXit614reteK3rXit614reteK3rXit614reteK3rRFiDKxBB9kTB0dDZlYZgg1GmxcQo7afu7x0g6cRAeLkO3qb7iuoHiyfKtkdbI62R1sjrZHWyOtkdbI62R1sjrZHQFpiwHGQnva3ZNROHR0NmVhmCDWEVeNIyWVDkHA8x/JHUqysLgg5EEGpSScMZFR4SflLkApWKVYxCjBhHGpvmRkST/AF9//8QALhEAAgEBBAgGAgMAAAAAAAAAAQIDAAQRITEQEhMUMDNBUTRAUnFy8IGCImGh/9oACAECAQE/APMRwRvECD/LvTKUJVhjo1W9JrVb0miCMxxo5Gja8fkUypOgPXoaIMb3MMQa3pPS1b0npanVZ4wVzzFardjVxGY4kCKke0bPOt6j7NTKloS8H2PamUoSrDHRDNsyQcVNb1H2akkjmvW78GpU1HZeGPDfpogZxIAvXOpYhILjn0NMpQlWGOmy8w/GrTzT7Dhjw36aLKBrMf6qSV0mPYXC6mVJ0BGfQ0ylCVYY6LLzD8atPNPsOGPDfpohfZuCcjgalhEtzKcahhlRr7wB1qWNZBd1GRplKEqwxqy8w/GrTzT7DhjGzYeg6UldMFOHajaZT2FJKyNrX3350ypaEBFKXgc3jGncuxY8OGfUGqwwrbw/RW3h+itvD9FbeH6K28P0VvEXf/KZUnS8H2NMpRirZjySSNGb1p2LsWbM+Z//xAAqEQACAgADBgYDAQAAAAAAAAABAgADETFREBITMDNxBCEyQILwFGGBQf/aAAgBAwEBPwD3D2ulhByisGGI2YjUTeGo57oHGBis1TYQEOvkc5+O+on476iKTU+B/sxGo5trMz7gnAfUQFqWwMVgwxGy2vfyznAfURkevA4/0Stt9QeWev8ALZaFKEt/kRyh/UVgwxG3xHoHeUdMcs9f5bPEele8StWqGusBalsDFYMMRs8R6B3lHTHLPX+WyxN9SBnK7dzFWHlLLK3XI4xHKH9RWDDETxHoHeUdMcs+V3y2tWj5iChBqY9asu7lpAWpbAwhbUziKEUKOXbVvneXOcGycGz6ZwbPpnBs+mcGz6ZwbNIpapsCO4ikMAR7J0VxgYqhQAPc/wD/2Q==', 'base64'); 3 | -------------------------------------------------------------------------------- /app/src/constants/log-rotate.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | /** 4 | * How many log files we should keep 5 | * @type {Number} 6 | */ 7 | KEEP_LAST_X_ENTRIES: 10, 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /app/src/constants/url.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Protocols, allowed to be opened from WebContents window 3 | * @type {Set} 4 | */ 5 | module.exports.WEBCONTENTS_ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); 6 | -------------------------------------------------------------------------------- /app/src/controller/offline-user.js: -------------------------------------------------------------------------------- 1 | const { Property } = require('../models').db.models; 2 | const Log = require('../utils/log'); 3 | 4 | const log = new Log('OfflineUser'); 5 | 6 | /** 7 | * Implements offline (local) user logic 8 | */ 9 | class OfflineUser { 10 | 11 | constructor() { 12 | 13 | /** 14 | * User properties 15 | * @type {Object} 16 | */ 17 | this.user = {}; 18 | 19 | } 20 | 21 | /** 22 | * Read user properties from local storage 23 | * @returns {OfflineUser|null} Returns nnull if data cannot be fetched 24 | */ 25 | static async readFromLocalStorage() { 26 | 27 | // Getting serialized local user data 28 | const serializedUser = await Property.findOne({ where: { key: 'local_user' } }); 29 | 30 | // Check is database entry exists 31 | if (!serializedUser) 32 | return null; 33 | 34 | // Trying to parse values from serialized data 35 | try { 36 | 37 | return JSON.parse(serializedUser.value); 38 | 39 | } catch (error) { 40 | 41 | // Log issue 42 | log.error('Error occured during offline user data read from storage', error); 43 | return null; 44 | 45 | } 46 | 47 | } 48 | 49 | /** 50 | * Bulk properties set 51 | * @param {Object} properties Properties to set 52 | */ 53 | async setProperties(properties) { 54 | 55 | // Check type of properties argument 56 | if (typeof properties !== 'object') 57 | throw new TypeError(`Properties object must be an object, but ${typeof properties} given`); 58 | 59 | this.user = { ...properties }; 60 | return this; 61 | 62 | } 63 | 64 | /** 65 | * Fetches user properties from local storage into buffer 66 | */ 67 | async fetch() { 68 | 69 | const usr = await OfflineUser.readFromLocalStorage(); 70 | 71 | if (!usr) 72 | return false; 73 | 74 | this.setProperties(usr); 75 | return true; 76 | 77 | } 78 | 79 | /** 80 | * Commits current offline user buffer to disk 81 | * @returns {Promise} True, if succeed 82 | */ 83 | async commit() { 84 | 85 | const serialized = JSON.stringify(this.user); 86 | const storedEntry = await Property.findOne({ where: { key: 'local_user' } }); 87 | 88 | // Update existing property if it is exists 89 | if (storedEntry) { 90 | 91 | await storedEntry.update({ value: serialized }); 92 | return true; 93 | 94 | } 95 | 96 | // Create new one if it is not exists 97 | const newEntry = new Property({ key: 'local_user', value: serialized }); 98 | await newEntry.save(); 99 | return true; 100 | 101 | } 102 | 103 | } 104 | 105 | module.exports = new OfflineUser(); 106 | -------------------------------------------------------------------------------- /app/src/controller/projects.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const api = require('../base/api'); 3 | const database = require('../models').db.models; 4 | const OfflineMode = require('../base/offline-mode'); 5 | const Log = require('../utils/log'); 6 | 7 | const log = new Log('Projects'); 8 | const projectEmitter = new EventEmitter(); 9 | 10 | /** 11 | * Formats projects for local storage 12 | * @param {Object} projects Object of project objects received from server 13 | * @return {Array} formattedProjects Array of formatted projects 14 | */ 15 | function formatProjects(projects) { 16 | 17 | const formatted = []; 18 | projects.forEach(project => { 19 | 20 | formatted.push({ 21 | externalId: project.id, 22 | externalUrl: null, 23 | name: project.name, 24 | description: project.description, 25 | source: project.source, 26 | updatedAt: project.updatedAt, 27 | screenshotsState: project.screenshotsState, 28 | }); 29 | 30 | }); 31 | 32 | return formatted; 33 | 34 | } 35 | 36 | /** 37 | * Sync local DB with remote storage 38 | * @param {Object[]} offlineProjects 39 | * @return {Promise[]>>} syncedProjects Synced (actualized) projects from local storage 40 | */ 41 | module.exports.syncProjects = async (offlineProjects = null) => { 42 | 43 | // Handle offline launch case 44 | if (OfflineMode.enabled && offlineProjects == null) { 45 | 46 | log.warning('Intercepting projects sync request due to offline mode'); 47 | return database.Project.findAll(); 48 | 49 | } 50 | 51 | const projectOptions = {}; 52 | let actualProjects = null; 53 | 54 | try { 55 | 56 | if (offlineProjects == null) { 57 | actualProjects = await api.projects.list(projectOptions); 58 | OfflineMode.restoreWithCheck(); 59 | } else { 60 | actualProjects = offlineProjects; 61 | } 62 | 63 | } catch (err) { 64 | 65 | if (err instanceof api.NetworkError) { 66 | 67 | log.warning('Connectivity error detected, triggering offline mode'); 68 | OfflineMode.trigger(); 69 | 70 | } else if (err instanceof api.ApiError) { 71 | log.warning('ApiError error detected, triggering offline mode'); 72 | OfflineMode.trigger(); 73 | } 74 | 75 | log.warning(`Intercepting projects listing fetch, due to error: ${err}`); 76 | return database.Project.findAll(); 77 | 78 | } 79 | 80 | const actualProjectsFormatted = formatProjects(actualProjects); 81 | 82 | const toUpdate = {}; 83 | const toCreate = []; 84 | const toDelete = []; 85 | 86 | const localProjects = await database.Project.findAll(); 87 | const localProjectsNumber = localProjects.length; 88 | 89 | // If there are no any local projects 90 | if (localProjectsNumber === 0) { 91 | 92 | await database.Project.bulkCreate(actualProjectsFormatted); 93 | return database.Project.findAll(); 94 | 95 | } 96 | 97 | actualProjectsFormatted.forEach(actualProject => { 98 | 99 | let found = false; 100 | 101 | localProjects.forEach(localProject => { 102 | 103 | // Skipping iteration, if the match for actualProject already found 104 | if (found) 105 | return; 106 | 107 | // Skpping iteration, if project identifiers aren't match 108 | if (String(localProject.externalId) !== String(actualProject.externalId)) 109 | return; 110 | 111 | // Checking differnce in last update time or screenshots state between local and remote entries 112 | if (Date.parse(localProject.updatedAt) < Date.parse(actualProject.updatedAt) || localProject.screenshotsState !== actualProject.screenshotsState) {} 113 | toUpdate[actualProject.externalId] = actualProject; 114 | 115 | // Set the "found" flag 116 | found = true; 117 | 118 | }); 119 | 120 | if (!found) 121 | toCreate.push(actualProject); 122 | 123 | }); 124 | 125 | 126 | const actualProjectsFormattedIDs = actualProjectsFormatted.map(p => p.externalId); 127 | 128 | localProjects.forEach(project => { 129 | 130 | if (!actualProjectsFormattedIDs.includes(parseInt(project.externalId, 10))) 131 | toDelete.push(project.externalId); 132 | 133 | }); 134 | 135 | // Should we create something new? 136 | if (toCreate.length > 0) 137 | await database.Project.bulkCreate(toCreate); 138 | 139 | // .. or should we remove existing entities? 140 | if (toDelete.length > 0) 141 | await database.Project.destroy({where: {externalId: toDelete}}); 142 | 143 | // Return all current entries if there are nothing to update 144 | if (Object.keys(toUpdate).length === 0) 145 | return database.Project.findAll(); 146 | 147 | // Performing update routine 148 | const results = Object 149 | .values(toUpdate) 150 | .map(project => database.Project.update(project, {where: {externalId: project.externalId}})); 151 | 152 | await Promise.all(results); 153 | return database.Project.findAll(); 154 | 155 | }; 156 | 157 | 158 | /** 159 | * Returning projects list from database 160 | * @return {Promise[]>|Error} Returns array of projects if succeed 161 | */ 162 | module.exports.getProjectsList = async () => database.Project.findAll(); 163 | 164 | module.exports.getProjectByInternalId = async internalId => database.Project.findByPk(internalId); 165 | 166 | module.exports.events = projectEmitter; 167 | -------------------------------------------------------------------------------- /app/src/controller/tracking-features.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require('events'); 2 | const { Property } = require('../models').db.models; 3 | const Log = require('../utils/log'); 4 | 5 | const log = new Log('TrackingFeatures'); 6 | const ScreenshotsState = require("../constants/ScreenshotsState"); 7 | 8 | class TrackingFeatures extends EventEmitter { 9 | 10 | /** 11 | * Retrieves a list of tracking features from User 12 | * @param {User} user 13 | * @returns {string[]} Array of monitoring featuers 14 | */ 15 | static parseUserFeatures(user) { 16 | 17 | const features = []; 18 | 19 | if (user.appMonitoringEnabled) { 20 | 21 | if (process.platform === 'win32') 22 | features.push('APP_MONITORING'); 23 | else 24 | log.warning('App monitoring isn\'t supported on platforms other then Windows'); 25 | 26 | } 27 | 28 | if (user.screenshotsState === ScreenshotsState.REQUIRED || user.screenshotsState === ScreenshotsState.OPTIONAL) { 29 | features.push('DESKTOP_SCREENSHOTS'); 30 | } else { 31 | features.push('DESKTOP_SCREENSHOTS_DISABLED'); 32 | } 33 | 34 | return features; 35 | 36 | } 37 | 38 | /** 39 | * Retrieve unacknowledged tracking features list if any 40 | * @returns {Promise.} List of unack'ed features, or null 41 | */ 42 | async retrieveUnacknowledged() { 43 | 44 | const featureListRow = await Property.findOne({ where: { key: 'tracking_features' } }); 45 | if (!featureListRow) 46 | return null; 47 | 48 | const featureList = JSON.parse(featureListRow.value); 49 | 50 | if (featureList.acknowledged) 51 | return null; 52 | 53 | // Update acknowledge flag in the database 54 | featureList.acknowledged = true; 55 | featureListRow.value = JSON.stringify(featureList); 56 | await featureListRow.save(); 57 | 58 | this.emit('acknowledged'); 59 | return featureList.features; 60 | 61 | } 62 | 63 | /** 64 | * Update features from User entry 65 | * @param {User} user 66 | * @param {Boolean} [acknowledged=false] Acknowledge changes on update 67 | * @returns {Promise.} List of new features if any 68 | */ 69 | async updateFromUser(user, acknowledged = false) { 70 | 71 | // Retrieve feature lists from DB and user 72 | const userFeatures = TrackingFeatures.parseUserFeatures(user); 73 | const dbFeaturesRow = await Property.findOne({ where: { key: 'tracking_features' } }); 74 | 75 | // If there is no feature list in persistence, create a new one 76 | if (!dbFeaturesRow) { 77 | 78 | // Create a new database property 79 | const flProperty = new Property({ 80 | key: 'tracking_features', 81 | value: JSON.stringify({ 82 | acknowledged, 83 | features: userFeatures, 84 | }), 85 | }); 86 | 87 | // Save it 88 | await flProperty.save(); 89 | 90 | // Reflect changes in the log file 91 | log.debug(`Created a new tracking features list: ${userFeatures}`); 92 | 93 | // Consider all features as new ones 94 | this.emit('features-changed', userFeatures); 95 | return userFeatures; 96 | 97 | } 98 | 99 | const dbFeatures = JSON.parse(dbFeaturesRow.value); 100 | 101 | // Find new features 102 | const newFeatures = userFeatures.filter(f => !dbFeatures.features.includes(f)); 103 | 104 | // Detect difference between feature lists 105 | const isFeatureListsDifferent = userFeatures 106 | 107 | // Get the left hand outer selection 108 | .filter(f => !dbFeatures.features.includes(f)) 109 | 110 | // Get the right hand outer selection 111 | .concat(dbFeatures.features.filter(f => !userFeatures.includes(f))) 112 | 113 | // Convert to boolean 114 | .length > 0; 115 | 116 | // Update database entry 117 | dbFeaturesRow.value = JSON.stringify({ 118 | features: userFeatures, 119 | acknowledged: acknowledged || !isFeatureListsDifferent, 120 | }); 121 | await dbFeaturesRow.save(); 122 | 123 | // Reflect changes in the log file 124 | log.debug(`Update tracking features list: ${userFeatures}`); 125 | 126 | // Notify application about new set of tracking features 127 | if (newFeatures.length > 0) { 128 | 129 | this.emit('features-changed', newFeatures); 130 | return newFeatures; 131 | 132 | } 133 | 134 | return null; 135 | 136 | } 137 | 138 | /** 139 | * Get list of active tracking features 140 | * @async 141 | * @returns {Promise.} 142 | */ 143 | async getCurrentFeatures() { 144 | 145 | const featureListRow = await Property.findOne({ where: { key: 'tracking_features' } }); 146 | if (!featureListRow) 147 | return null; 148 | 149 | const featureList = JSON.parse(featureListRow.value); 150 | this.emit('fetched', featureList.features); 151 | return featureList.features; 152 | 153 | } 154 | 155 | } 156 | 157 | module.exports = new TrackingFeatures(); 158 | -------------------------------------------------------------------------------- /app/src/migrations/20190401162647-create-project.js: -------------------------------------------------------------------------------- 1 | /* eslint arrow-body-style: 0 */ 2 | /* eslint implicit-arrow-linebreak: 0 */ 3 | 4 | module.exports = { 5 | 6 | up: (queryInterface, Sequelize) => queryInterface.createTable('Projects', { 7 | 8 | id: { 9 | primaryKey: true, 10 | type: Sequelize.UUID, 11 | defaultValue: Sequelize.UUIDV4, 12 | }, 13 | name: { type: Sequelize.STRING }, 14 | description: { type: Sequelize.TEXT }, 15 | externalId: { type: Sequelize.STRING }, 16 | externalUrl: { type: Sequelize.STRING }, 17 | createdAt: { type: Sequelize.DATE, allowNull: false }, 18 | updatedAt: { type: Sequelize.DATE, allowNull: false }, 19 | deletedAt: { type: Sequelize.DATE, allowNull: true }, 20 | 21 | }), 22 | 23 | down: queryInterface => queryInterface.dropTable('Projects'), 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /app/src/migrations/20190401185842-create-task.js: -------------------------------------------------------------------------------- 1 | /* eslint arrow-body-style: 0 */ 2 | /* eslint implicit-arrow-linebreak: 0 */ 3 | 4 | module.exports = { 5 | up: (queryInterface, Sequelize) => 6 | queryInterface.createTable('Tasks', { 7 | 8 | id: { 9 | primaryKey: true, 10 | type: Sequelize.UUID, 11 | defaultValue: Sequelize.UUIDV4, 12 | }, 13 | projectId: { type: Sequelize.UUID }, 14 | externalId: { type: Sequelize.STRING }, 15 | externalUrl: { type: Sequelize.STRING }, 16 | externalStatus: { type: Sequelize.STRING }, 17 | name: { type: Sequelize.STRING }, 18 | description: { type: Sequelize.TEXT }, 19 | priority: { type: Sequelize.STRING }, 20 | status: { type: Sequelize.STRING }, 21 | createdAt: { type: Sequelize.DATE, allowNull: false }, 22 | updatedAt: { type: Sequelize.DATE, allowNull: false }, 23 | deletedAt: { type: Sequelize.DATE, allowNull: true }, 24 | 25 | }), 26 | 27 | down: queryInterface => queryInterface.dropTable('Tasks'), 28 | 29 | }; 30 | -------------------------------------------------------------------------------- /app/src/migrations/20190401210750-create-interval.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | // Migration apply flow 4 | up: (queryInterface, Sequelize) => queryInterface.createTable('Intervals', { 5 | id: { 6 | primaryKey: true, 7 | type: Sequelize.UUID, 8 | defaultValue: Sequelize.UUIDV4, 9 | }, 10 | taskId: { type: Sequelize.UUID }, 11 | capturedAt: { type: Sequelize.DATE }, 12 | length: { type: Sequelize.INTEGER }, 13 | eventsMouse: { type: Sequelize.INTEGER }, 14 | eventsKeyboard: { type: Sequelize.INTEGER }, 15 | screenshot: { type: Sequelize.BLOB }, 16 | createdAt: { type: Sequelize.DATE, allowNull: false }, 17 | updatedAt: { type: Sequelize.DATE, allowNull: false }, 18 | deletedAt: { type: Sequelize.DATE, allowNull: true }, 19 | 20 | }), 21 | 22 | // Migration rollback flow 23 | down: queryInterface => queryInterface.dropTable('Intervals'), 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /app/src/migrations/20190401213740-create-track.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: (queryInterface, Sequelize) => queryInterface.createTable('Tracks', { 4 | id: { 5 | primaryKey: true, 6 | type: Sequelize.UUID, 7 | defaultValue: Sequelize.UUIDV4, 8 | }, 9 | day: { type: Sequelize.DATE }, 10 | taskId: { type: Sequelize.UUID }, 11 | overallTime: { type: Sequelize.INTEGER }, 12 | createdAt: { type: Sequelize.DATE, allowNull: false }, 13 | updatedAt: { type: Sequelize.DATE, allowNull: false }, 14 | deletedAt: { type: Sequelize.DATE, allowNull: true }, 15 | 16 | }), 17 | 18 | down: queryInterface => queryInterface.dropTable('Tracks'), 19 | 20 | }; 21 | -------------------------------------------------------------------------------- /app/src/migrations/20190529213233-intervals-table-structure-changed-to-boundaries.js: -------------------------------------------------------------------------------- 1 | /* eslint arrow-body-style: 0 */ 2 | /* eslint implicit-arrow-linebreak: 0 */ 3 | 4 | module.exports = { 5 | 6 | up: (queryInterface, Sequelize) => 7 | queryInterface.sequelize.transaction(t => 8 | queryInterface.renameColumn('Intervals', 'capturedAt', 'startAt', { transaction: t }) 9 | .then(() => queryInterface.renameColumn('Intervals', 'length', 'endAt', { transaction: t })) 10 | .then(() => queryInterface.changeColumn('Intervals', 'endAt', { type: Sequelize.DATE }, { transaction: t }))), 11 | 12 | down: (queryInterface, Sequelize) => 13 | queryInterface.sequelize.transaction(t => 14 | queryInterface.removeColumn('Intervals', 'userId', { transaction: t }) 15 | .then(() => queryInterface.changeColumn('Intervals', 'endAt', { type: Sequelize.INTEGER }, { transaction: t })) 16 | .then(() => queryInterface.renameColumn('Intervals', 'endAt', 'length', { transaction: t })) 17 | .then(() => queryInterface.renameColumn('Intervals', 'capturedAt', 'startAt', { transaction: t }))), 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /app/src/migrations/20190816003133-intervals-table-userid-added-.js: -------------------------------------------------------------------------------- 1 | /* eslint arrow-body-style: 0 */ 2 | /* eslint implicit-arrow-linebreak: 0 */ 3 | 4 | module.exports = { 5 | 6 | up: (queryInterface, Sequelize) => queryInterface.addColumn('Intervals', 'userId', { 7 | 8 | type: Sequelize.UUID, 9 | defaultValue: Sequelize.UUIDV4, 10 | 11 | }), 12 | 13 | down: queryInterface => queryInterface.removeColumn('Intervals', 'userId'), 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /app/src/migrations/20190816163400-create-property.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: (queryInterface, Sequelize) => queryInterface.createTable('Properties', { 4 | id: { 5 | primaryKey: true, 6 | type: Sequelize.UUID, 7 | defaultValue: Sequelize.UUIDV4, 8 | }, 9 | key: { type: String }, 10 | value: { type: String }, 11 | createdAt: { type: Sequelize.DATE, allowNull: false }, 12 | updatedAt: { type: Sequelize.DATE, allowNull: false }, 13 | deletedAt: { type: Sequelize.DATE, allowNull: true }, 14 | }), 15 | 16 | down: queryInterface => queryInterface.dropTable('Properties'), 17 | 18 | }; 19 | -------------------------------------------------------------------------------- /app/src/migrations/20200330060000-projects-source-prop.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: (queryInterface, Sequelize) => queryInterface.addColumn('Projects', 'source', { type: Sequelize.STRING }), 4 | down: queryInterface => queryInterface.removeColumn('Projects', 'source'), 5 | 6 | }; 7 | -------------------------------------------------------------------------------- /app/src/migrations/20200709112600-change-interval-properties.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: async (queryInterface, Sequelize) => Promise.all([ 4 | await queryInterface.removeColumn('Intervals', 'eventsMousbe'), 5 | await queryInterface.removeColumn('Intervals', 'eventsKeyboard'), 6 | await queryInterface.addColumn('Intervals', 'systemActivity', { type: Sequelize.INTEGER }), 7 | await queryInterface.addColumn('Intervals', 'keyboardActivity', { type: Sequelize.INTEGER }), 8 | await queryInterface.addColumn('Intervals', 'mouseActivity', { type: Sequelize.INTEGER }), 9 | ]), 10 | 11 | down: async (queryInterface, Sequelize) => Promise.all([ 12 | await queryInterface.addColumn('Intervals', 'eventsMouse', { type: Sequelize.INTEGER }), 13 | await queryInterface.addColumn('Intervals', 'eventsKeyboard', { type: Sequelize.INTEGER }), 14 | await queryInterface.removeColumn('Intervals', 'systemActivity'), 15 | await queryInterface.removeColumn('Intervals', 'keyboardActivity'), 16 | await queryInterface.removeColumn('Intervals', 'mouseActivity'), 17 | ]), 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /app/src/migrations/20200715105642-tasks-pinorder-property-added.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: async (queryInterface, Sequelize) => { 4 | 5 | queryInterface.addColumn('Tasks', 'pinOrder', { 6 | 7 | type: Sequelize.INTEGER, 8 | defaultValue: null, 9 | 10 | }); 11 | 12 | }, 13 | 14 | down: queryInterface => queryInterface.removeColumn('Tasks', 'pinOrder'), 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /app/src/migrations/20210114201900-add-synced-flag-to-interval.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: async (queryInterface, Sequelize) => { 4 | 5 | queryInterface.addColumn('Intervals', 'synced', { 6 | 7 | type: Sequelize.BOOLEAN, 8 | defaultValue: false, 9 | 10 | }); 11 | 12 | }, 13 | 14 | down: queryInterface => queryInterface.removeColumn('Intervals', 'pinOrder'), 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /app/src/migrations/20210115220000-add-remoteid-to-interval.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: async (queryInterface, Sequelize) => { 4 | 5 | queryInterface.addColumn('Intervals', 'remoteId', { 6 | 7 | type: Sequelize.STRING, 8 | defaultValue: false, 9 | 10 | }); 11 | 12 | }, 13 | 14 | down: queryInterface => queryInterface.removeColumn('Intervals', 'remoteId'), 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /app/src/migrations/20240000000001-change-interval-uuid-type.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: async (queryInterface, Sequelize) => { 4 | 5 | await queryInterface.changeColumn('Intervals', 'id', { 6 | defaultValue: Sequelize.UUIDV1, 7 | }); 8 | 9 | }, 10 | 11 | down: queryInterface => queryInterface.changeColumn('Intervals', 'id', { 12 | defaultValue: Sequelize.UUIDV4 13 | }), 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /app/src/migrations/20240000000002-projects-add-screenshots-state.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | up: async (queryInterface, Sequelize) => { 4 | 5 | await queryInterface.addColumn('Projects', 'screenshotsState', { 6 | type: Sequelize.TINYINT, 7 | allowNull: true, 8 | defaultValue: null, 9 | }); 10 | 11 | }, 12 | 13 | down: queryInterface => queryInterface.removeColumn('Projects', 'screenshotsState'), 14 | 15 | }; 16 | -------------------------------------------------------------------------------- /app/src/models/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const Umzug = require('umzug'); 4 | const Sequelize = require('sequelize'); 5 | const config = require('../base/config'); 6 | const Log = require('../utils/log'); 7 | 8 | const log = new Log('Database'); 9 | 10 | module.exports.db = {}; 11 | module.exports.db.models = {}; 12 | 13 | const initMigrations = sequelize => new Promise((resolve, reject) => { 14 | 15 | // Setup migration engine 16 | const umzug = new Umzug({ 17 | 18 | // Sequelize storage dialect 19 | storage: 'sequelize', 20 | storageOptions: { sequelize }, 21 | 22 | // Define migration settings 23 | migrations: { 24 | params: [ 25 | sequelize.getQueryInterface(), 26 | sequelize.constructor, 27 | () => reject(new Error('Unexpected callback-styled migrations')), 28 | ], 29 | path: path.resolve(__dirname, '..', 'migrations'), 30 | pattern: /\.js$/, 31 | }, 32 | 33 | }); 34 | 35 | // Run migrations 36 | umzug.up().then(() => { 37 | 38 | // Reading all files in this directory 39 | fs.readdirSync(path.resolve(__dirname)) 40 | 41 | // Filter only JavaScript source files except this one 42 | .filter(f => ((f.indexOf('.') !== 0) && (f !== path.basename(__filename)) && (f.slice(-3) === '.js'))) 43 | 44 | // Import all of them as Sequelize models 45 | .forEach(file => { 46 | 47 | // eslint-disable-next-line global-require, import/no-dynamic-require 48 | const model = require(path.resolve(__dirname, './', file))(sequelize, Sequelize.DataTypes); 49 | module.exports.db.models[model.name] = model; 50 | 51 | }); 52 | 53 | 54 | // Iterate over imported models 55 | Object.keys(module.exports.db.models).forEach(modelName => { 56 | 57 | // Building described relations 58 | if (module.exports.db.models[modelName].associate) 59 | module.exports.db.models[modelName].associate(module.exports.db.models); 60 | 61 | }); 62 | 63 | // Export things 64 | module.exports.db.sequelize = sequelize; 65 | module.exports.db.Sequelize = Sequelize; 66 | 67 | log.debug('Migrations applied successfully'); 68 | 69 | // Resolve promise 70 | return resolve(); 71 | 72 | }); 73 | 74 | }); 75 | 76 | // Database init promise 77 | module.exports.init = async () => { 78 | 79 | const sequelize = new Sequelize(config.localDB.opts); 80 | await sequelize.authenticate(); 81 | await initMigrations(sequelize); 82 | log.debug('Database successfully initialized'); 83 | return true; 84 | 85 | }; 86 | -------------------------------------------------------------------------------- /app/src/models/interval.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | 3 | const Interval = sequelize.define('Interval', { 4 | 5 | id: { 6 | primaryKey: true, 7 | type: DataTypes.UUID, 8 | defaultValue: DataTypes.UUIDV1, 9 | }, 10 | 11 | // Remote identifier of the related task 12 | taskId: DataTypes.STRING, 13 | 14 | // Timestamp of the moment when this interval was started 15 | startAt: DataTypes.DATE, 16 | 17 | // Timestamp of the moment when this interval was finished 18 | endAt: DataTypes.DATE, 19 | 20 | // Percent of system activity 21 | systemActivity: DataTypes.INTEGER, 22 | 23 | // Percent of keyboard activity 24 | keyboardActivity: DataTypes.INTEGER, 25 | 26 | // Percent of mouse activity 27 | mouseActivity: DataTypes.INTEGER, 28 | 29 | // Associated screenshot in JPEG 30 | screenshot: DataTypes.BLOB, 31 | 32 | // Associated user ID 33 | userId: DataTypes.INTEGER, 34 | 35 | // Is this interval synced? 36 | synced: DataTypes.BOOLEAN, 37 | 38 | // Identifier of this interval on remote 39 | remoteId: DataTypes.STRING, 40 | 41 | }, {}); 42 | 43 | // Building relations 44 | Interval.associate = models => { 45 | 46 | Interval.belongsTo(models.Task); 47 | Interval.hasOne(models.Task, { foreignKey: 'externalId', sourceKey: 'taskId' }); 48 | 49 | }; 50 | 51 | // Return model 52 | return Interval; 53 | 54 | }; 55 | -------------------------------------------------------------------------------- /app/src/models/project.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | 3 | const Project = sequelize.define('Project', { 4 | 5 | id: { 6 | primaryKey: true, 7 | type: DataTypes.UUID, 8 | defaultValue: DataTypes.UUIDV4, 9 | }, 10 | 11 | // External project identifier (in API) 12 | externalId: DataTypes.STRING, 13 | 14 | // Direct link to the external task location (i.e. card in Trello or issue in Redmine) 15 | externalUrl: DataTypes.STRING, 16 | 17 | // Human-readable project name 18 | name: DataTypes.STRING, 19 | 20 | // Human-readable project description 21 | description: DataTypes.TEXT, 22 | 23 | // Project source (like "internal" or "gitlab" / "jira" / "trello") 24 | source: DataTypes.STRING, 25 | 26 | screenshotsState: DataTypes.TINYINT, 27 | 28 | }, { timestamps: true, paranoid: true }); 29 | 30 | // Define relations 31 | Project.associate = models => Project.hasMany(models.Task); 32 | 33 | // Return model 34 | return Project; 35 | 36 | }; 37 | -------------------------------------------------------------------------------- /app/src/models/property.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | 3 | const Property = sequelize.define('Property', { 4 | 5 | // UUID 6 | id: { 7 | 8 | primaryKey: true, 9 | type: DataTypes.UUID, 10 | defaultValue: DataTypes.UUIDV4, 11 | 12 | }, 13 | 14 | // External project identifier (in API) 15 | key: DataTypes.STRING, 16 | 17 | // Direct link to the external task location (i.e. card in Trello or issue in Redmine) 18 | value: DataTypes.STRING, 19 | 20 | }, { timestamps: true, paranoid: true }); 21 | 22 | // Return model 23 | return Property; 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /app/src/models/task.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Replaces invalid externalUrl with null 3 | * @param {Object} instance Sequelize model instance 4 | */ 5 | const invalidUrlsNuller = instance => { 6 | 7 | // Checking URL field prescense 8 | if (typeof instance.externalUrl === 'undefined' || !instance.externalUrl) 9 | return; 10 | 11 | // Checking for the wrong values 12 | if (instance.externalUrl.toLowerCase() === 'url') 13 | instance.externalUrl = null; // eslint-disable-line no-param-reassign 14 | 15 | }; 16 | 17 | module.exports = (sequelize, DataTypes) => { 18 | 19 | const Task = sequelize.define('Task', { 20 | 21 | // Parent project UUID 22 | id: { 23 | primaryKey: true, 24 | type: DataTypes.UUID, 25 | defaultValue: DataTypes.UUIDV4, 26 | }, 27 | 28 | // Task identifier in external system 29 | externalId: DataTypes.STRING, 30 | 31 | // Direct URL to this task in external system 32 | externalUrl: DataTypes.STRING, 33 | 34 | // Status of this task in external system (in_progress / new / on_hold / etc) 35 | externalStatus: DataTypes.STRING, 36 | 37 | // Human-readable name of this task 38 | name: DataTypes.STRING, 39 | 40 | // Human-readable description 41 | description: DataTypes.TEXT, 42 | 43 | // Task priority 44 | priority: DataTypes.STRING, 45 | 46 | // Internal task status (active / inactive) 47 | status: DataTypes.STRING, 48 | 49 | // Identifier of local project 50 | projectId: DataTypes.UUID, 51 | 52 | // Pin indicator 53 | pinOrder: DataTypes.INTEGER, 54 | 55 | }, { timestamps: true, paranoid: true }); 56 | 57 | // Build relations 58 | Task.associate = models => { 59 | 60 | // One Task belongs to one Project 61 | Task.belongsTo(models.Project, { foreignKey: 'projectId' }); 62 | 63 | // One Task has many Intervals 64 | Task.hasMany(models.Interval, { as: 'Interval', foreignKey: 'taskId', sourceKey: 'externalId' }); 65 | 66 | // One Task has many Tracks 67 | Task.hasMany(models.Track); 68 | 69 | }; 70 | 71 | // Fix for various shit in URL field 72 | Task.addHook('beforeSave', invalidUrlsNuller); 73 | Task.addHook('beforeCreate', invalidUrlsNuller); 74 | Task.addHook('beforeUpdate', invalidUrlsNuller); 75 | Task.addHook('beforeUpsert', invalidUrlsNuller); 76 | 77 | // Return model 78 | return Task; 79 | 80 | }; 81 | -------------------------------------------------------------------------------- /app/src/models/track.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | 3 | const Track = sequelize.define('Track', { 4 | id: { 5 | primaryKey: true, 6 | type: DataTypes.UUID, 7 | defaultValue: DataTypes.UUIDV4, 8 | }, 9 | 10 | // Date of this track 11 | day: DataTypes.DATE, 12 | 13 | // Identifier of the tracked task 14 | taskId: DataTypes.UUID, 15 | 16 | // Overall tracked time 17 | overallTime: DataTypes.INTEGER, 18 | 19 | }, {}); 20 | 21 | // Building relations 22 | Track.associate = models => Track.belongsTo(models.Task, { foreignKey: 'taskId' }); 23 | 24 | // Returning model 25 | return Track; 26 | 27 | }; 28 | -------------------------------------------------------------------------------- /app/src/routes/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | const { ipcMain } = require('electron'); 3 | const IPCRouter = require('@amazingcat/electron-ipc-router'); 4 | const Logger = require('../utils/log'); 5 | 6 | const log = new Logger('Router'); 7 | log.debug('Starting routes load'); 8 | 9 | // Creating instance of IPC router 10 | const router = new IPCRouter(ipcMain); 11 | 12 | // Expose router instance 13 | module.exports.router = router; 14 | 15 | // Proxy IPC.setWebContents method 16 | module.exports.setWebContents = wc => router.setWebContents(wc); 17 | 18 | require('./authentication.js')(router); 19 | require('./misc.js')(router); 20 | require('./projects.js')(router); 21 | require('./task-tracking.js')(router); 22 | require('./tasks.js')(router); 23 | require('./time.js')(router); 24 | require('./translation.js')(router); 25 | require('./user-preferences.js')(router); 26 | require('./offline-mode.js')(router); 27 | require('./intervals.js')(router); 28 | -------------------------------------------------------------------------------- /app/src/routes/intervals.js: -------------------------------------------------------------------------------- 1 | const Logger = require('../utils/log'); 2 | const Interval = require('../controller/time-intervals'); 3 | const TaskTracker = require('../base/task-tracker'); 4 | const {zip} = require("fflate"); 5 | const log = new Logger('Router:Intervals'); 6 | 7 | module.exports = router => { 8 | 9 | /* Returns instances from the latest intervals queue */ 10 | router.serve('interval/get-intervals-queue', async req => { 11 | 12 | try { 13 | 14 | // Get latest intervals from queue 15 | let intervals = (await Interval.fetchIntervalsQueue()); 16 | 17 | // Encode screenshots in Base64 18 | intervals = intervals.map(interval => { 19 | 20 | const instance = {...interval.dataValues}; 21 | 22 | if (instance.screenshot) 23 | instance.screenshot = interval.screenshot.toString('base64'); 24 | 25 | if (instance.Task) 26 | instance.Task = {...instance.Task.dataValues}; 27 | 28 | return instance; 29 | 30 | }); 31 | 32 | return req.send(200, intervals); 33 | 34 | } catch (err) { 35 | 36 | log.error('ERTINT00', 'Error occured during interval queue fetch', err); 37 | return req.send(500, {message: 'Error occured during interval queue fetch'}); 38 | 39 | } 40 | 41 | }); 42 | 43 | /* Remove interval from queue */ 44 | router.serve('interval/remove', async req => { 45 | 46 | try { 47 | 48 | await Interval.removeInterval(req.packet.body.task.intervalId); 49 | 50 | TaskTracker.emit('interval-removed', req.packet.body); 51 | 52 | return req.send(204, {}); 53 | 54 | } catch (err) { 55 | 56 | log.error('ERTINT01', 'Error occured during interval removal from queue', err); 57 | return req.send(500, {message: 'Error occured interval removal'}); 58 | 59 | } 60 | 61 | }); 62 | 63 | /* Get not synced intervals amount */ 64 | router.serve('interval/not-synced-amount', async req => { 65 | 66 | try { 67 | 68 | const amount = (await Interval.fetchNotSyncedIntervalsAmount()); 69 | 70 | return req.send(200, {amount}); 71 | 72 | } catch (err) { 73 | 74 | log.error('ERTINT02', 'Error occurred during not synced intervals amount fetch', err); 75 | return req.send(500, {message: 'Error occurred during not synced intervals amount fetch'}); 76 | 77 | } 78 | 79 | }); 80 | 81 | /* Get not synced intervals amount */ 82 | router.serve('interval/not-synced-screenshots-amount', async req => { 83 | 84 | try { 85 | 86 | const amount = (await Interval.fetchNotSyncedScreenshotsAmount()); 87 | 88 | return req.send(200, {amount}); 89 | 90 | } catch (err) { 91 | 92 | log.error('ERTINT02', 'Error occurred during not synced screenshots amount fetch', err); 93 | return req.send(500, {message: 'Error occurred during not synced screenshots amount fetch'}); 94 | 95 | } 96 | 97 | }); 98 | 99 | /* Get not synced intervals */ 100 | router.serve('interval/export-deferred', async req => { 101 | 102 | try { 103 | 104 | const intervals = (await Interval.fetchNotSyncedIntervals()) 105 | .map(interval => ({ 106 | ...interval.dataValues, 107 | start_at: new Date(interval.dataValues.start_at).toISOString(), 108 | end_at: new Date(interval.dataValues.end_at).toISOString() 109 | })); 110 | 111 | return req.send(200, intervals); 112 | 113 | } catch (error) { 114 | 115 | error.context = {}; 116 | const crypto = require("crypto"); 117 | error.context.client_trace_id = crypto.randomUUID(); 118 | 119 | log.error('ERTINT03', 'Error occurred during not synced intervals fetch', error); 120 | return req.send(500, { 121 | message: 'Error occurred during not synced intervals fetch', 122 | error: JSON.parse(JSON.stringify(error)), 123 | } 124 | ); 125 | 126 | } 127 | 128 | }); 129 | 130 | /* Get not synced intervals */ 131 | router.serve('interval/export-deferred-screenshots', async req => { 132 | 133 | try { 134 | const screenshots = (await Interval.fetchNotSyncedScreenshots()) 135 | .reduce((acc, {dataValues}) => { 136 | const el = dataValues; 137 | const key = `${el.user_id}_${el.screenshot_id}` 138 | acc[key] = el.screenshot; 139 | return acc; 140 | }, {}); 141 | 142 | zip(screenshots, { 143 | level: 1, 144 | mtime: new Date() 145 | }, async (error, data) => { 146 | // Save the ZIP file 147 | if (error) { 148 | error.context = {}; 149 | const crypto = require("crypto"); 150 | error.context.client_trace_id = crypto.randomUUID(); 151 | 152 | log.error('ERTINT03', 'Screenshots zipping error', error); 153 | return req.send(500, { 154 | message: 'Screenshots zipping error', 155 | error: JSON.parse(JSON.stringify(error)), 156 | } 157 | ); 158 | } 159 | return req.send(200, data); 160 | }) 161 | } catch (error) { 162 | error.context = {}; 163 | const crypto = require("crypto"); 164 | error.context.client_trace_id = crypto.randomUUID(); 165 | 166 | log.error('ERTINT03', 'Error occurred during not synced intervals fetch', error); 167 | return req.send(500, { 168 | message: 'Error occurred during not synced intervals fetch', 169 | error: JSON.parse(JSON.stringify(error)), 170 | } 171 | ); 172 | 173 | } 174 | 175 | }); 176 | 177 | log.debug('Loaded'); 178 | 179 | }; 180 | -------------------------------------------------------------------------------- /app/src/routes/misc.js: -------------------------------------------------------------------------------- 1 | const Logger = require('../utils/log'); 2 | const config = require('../base/config'); 3 | const update = require('../base/update'); 4 | const osIntegration = require('../base/os-integration'); 5 | const trackingFeatures = require('../controller/tracking-features'); 6 | const {Property} = require('../models').db.models; 7 | const api = require('../base/api'); 8 | const OfflineMode = require('../base/offline-mode'); 9 | const TrackingFeatures = require('../controller/tracking-features'); 10 | const auth = require("../base/authentication"); 11 | 12 | const log = new Logger('Router:Miscellaneous'); 13 | log.debug('Loaded'); 14 | 15 | module.exports = router => { 16 | 17 | // Handle window controls behavior 18 | router.serve('window/controls-close', () => osIntegration.windowCloseRequest()); 19 | 20 | // Handle minimize icon click 21 | router.serve('window/controls-minimize', () => osIntegration.windowMinimizeRequest()); 22 | 23 | // Set tracker on focus 24 | router.serve('window/focus', () => osIntegration.windowFocus()); 25 | 26 | // Development mode detect 27 | router.serve('misc/is-indev', req => req.send(200, { indev: config.isDeveloperModeEnabled })); 28 | 29 | // Handle update notification request 30 | router.serve('misc/update-available', async req => { 31 | 32 | const version = await update.retrieveUpdate(); 33 | return req.send(200, version || { version: null }); 34 | 35 | }); 36 | 37 | router.serve('misc/get-tracking-features', async req => { 38 | const features = await trackingFeatures.getCurrentFeatures(); 39 | return req.send(200, {features}); 40 | }) 41 | 42 | TrackingFeatures.on('features-changed', features => { 43 | router.emit('misc/features-changed', features); 44 | }) 45 | router.serve('misc/update-tracking-features', async req => { 46 | await auth.getCurrentUser(true); 47 | return req.send(200, {}); 48 | }); 49 | 50 | // Handle unacknowledged tracking features poll request 51 | router.serve('misc/unacknowledged-tracking-features', async req => { 52 | 53 | const features = await trackingFeatures.retrieveUnacknowledged(); 54 | return req.send(200, {features}); 55 | 56 | }); 57 | 58 | router.serve('offline-sync/get-public-key', async req => { 59 | const key = await Property.findOne({where: {key: 'offline-sync_public-key'}}); 60 | 61 | if (!key && !OfflineMode.enabled) { 62 | try { 63 | const publicKey = await api.offlineSync.getPublicKey(); 64 | const newEntry = new Property({key: 'offline-sync_public-key', value: publicKey}); 65 | await newEntry.save(); 66 | return req.send(200, {key: newEntry.value}) 67 | } catch (error) { 68 | error.context = {}; 69 | const crypto = require("crypto"); 70 | error.context.client_trace_id = crypto.randomUUID(); 71 | 72 | log.error('Error occurred during offline-sync encryption key fetching', error); 73 | return req.send(500, { 74 | message: 'Error occurred during offline-sync encryption key fetching', 75 | error: JSON.parse(JSON.stringify(error)), 76 | } 77 | ); 78 | } 79 | } else if (!key && OfflineMode.enabled) { 80 | return req.send(500, { 81 | message: `In order to export intervals, Cattr client app needs to fetch encryption keys from the server first.`, 82 | }); 83 | } 84 | 85 | 86 | return req.send(200, {key: key.value}) 87 | }); 88 | 89 | }; 90 | -------------------------------------------------------------------------------- /app/src/routes/offline-mode.js: -------------------------------------------------------------------------------- 1 | const Logger = require('../utils/log'); 2 | const OfflineMode = require('../base/offline-mode'); 3 | 4 | const log = new Logger('Router:Offline'); 5 | log.debug('Loaded'); 6 | 7 | module.exports = router => { 8 | 9 | OfflineMode.on('offline', () => router.emit('offline/status', { state: true })); 10 | OfflineMode.on('connection-restored', () => { 11 | router.emit('offline/status', { state: false }); 12 | router.emit('misc/set-offline-sync-encryption-key', {}); 13 | }); 14 | router.serve('offline/request-status', req => req.send(200, { state: OfflineMode._isEnabled })); 15 | 16 | }; 17 | -------------------------------------------------------------------------------- /app/src/routes/projects.js: -------------------------------------------------------------------------------- 1 | const Logger = require('../utils/log'); 2 | const Projects = require('../controller/projects'); 3 | const {UIError} = require('../utils/errors'); 4 | const auth = require("../base/authentication"); 5 | 6 | const log = new Logger('Router:Projects'); 7 | log.debug('Loaded'); 8 | 9 | /** 10 | * Truncates all uneccessery fields from Sequelize objects 11 | * @param {Array} instances [description] 12 | * @return {Array} [description] 13 | */ 14 | const purifyInstances = instances => instances.map(i => Object.assign(i.dataValues)); 15 | 16 | module.exports = router => { 17 | 18 | // Sync projects from server 19 | router.serve('projects/sync', async request => { 20 | 21 | try { 22 | 23 | const currentUser = await auth.getCurrentUser(); 24 | 25 | if (request.packet.body?.offlineImport && currentUser?.id) { 26 | if (typeof request.packet.body.offlineImport.id !== 'number' 27 | || !Array.isArray(request.packet.body.offlineImport.projects)) { 28 | throw new UIError(400, 'Wrong format', 'ERPS400'); 29 | } 30 | if (request.packet.body.offlineImport.id !== currentUser.id) { 31 | throw new UIError(400, 'Unable to import data from another user', 'ERPS401'); 32 | } 33 | } 34 | 35 | // Starting sync routine 36 | log.debug('Projects sync initiated by renderer'); 37 | const projects = await Projects.syncProjects(request.packet.body?.offlineImport?.projects); 38 | 39 | // Returning response 40 | log.debug('Projects successfully synced'); 41 | return request.send(200, {projects: purifyInstances(projects)}); 42 | 43 | } catch (error) { 44 | 45 | // Pass UIErrors directly to renderer 46 | if (error instanceof UIError) 47 | // {error: error.error} means we are passing error that initially triggered UIError 48 | return request.send(error.code, { 49 | message: error.message, 50 | id: error.errorId, 51 | error: error.error == null ? error.error : JSON.parse(JSON.stringify(error.error)) 52 | }); 53 | 54 | // It'll be extremely weird if real errors will occur there. We should log them. 55 | log.error('Operating error occured in projects sync route', error); 56 | request.send(500, {message: 'Internal error occured', id: 'ERTP500'}); 57 | 58 | return false; 59 | 60 | } 61 | 62 | }); 63 | 64 | // Returns list of project from local database 65 | router.serve('projects/list', async request => { 66 | 67 | try { 68 | 69 | // Starting sync routine 70 | log.debug('Local projects fetch initiated by renderer'); 71 | const projects = await Projects.getProjectsList(); 72 | 73 | // Returning response 74 | log.debug('Projects successfully fetched from local databasse'); 75 | return request.send(200, {projects: purifyInstances(projects)}); 76 | 77 | } catch (error) { 78 | 79 | // Pass UIErrors directly to renderer 80 | if (error instanceof UIError) 81 | // {error: error.error} means we are passing error that initially triggered UIError 82 | return request.send(error.code, { 83 | message: error.message, 84 | id: error.errorId, 85 | error: error.error == null ? error.error : JSON.parse(JSON.stringify(error.error)) 86 | }); 87 | 88 | // It'll be extremely weird if real errors will occur there. We should log them. 89 | log.error('Operating error occured in projects list route', error); 90 | request.send(500, {message: 'Internal error occured', id: 'ERTP501'}); 91 | 92 | return false; 93 | 94 | } 95 | 96 | }); 97 | 98 | }; 99 | -------------------------------------------------------------------------------- /app/src/routes/task-tracking.js: -------------------------------------------------------------------------------- 1 | const Logger = require('../utils/log'); 2 | const { UIError } = require('../utils/errors'); 3 | const TaskTracker = require('../base/task-tracker'); 4 | const OSIntegration = require('../base/os-integration'); 5 | require('../base/deferred-handler'); 6 | 7 | const log = new Logger('Router:Tracking'); 8 | log.debug('Loaded'); 9 | 10 | module.exports = router => { 11 | 12 | // Start tracking 13 | router.serve('tracking/start', async request => { 14 | 15 | try { 16 | 17 | // Object to handle packet body 18 | const requestData = request.packet.body; 19 | 20 | // Checking is taskId parameter exists 21 | if (!requestData.taskId) 22 | throw new UIError(400, 'Incorrect task identifier', 'ERTR400'); 23 | 24 | // Check tracking availability 25 | const trackingAvailability = await OSIntegration.constructor.getTrackingAvailability(); 26 | 27 | // Return error status if tracking is unavailable 28 | if (!trackingAvailability.available) 29 | return request.send(501, { reason: trackingAvailability.reason }); 30 | 31 | // Starting tracker 32 | await TaskTracker.start(requestData.taskId); 33 | 34 | // Return successful response 35 | return request.send(200, {}); 36 | 37 | } catch (error) { 38 | 39 | // Pass UIErrors directly to renderer 40 | if (error instanceof UIError) 41 | // {error: error.error} means we are passing error that initially triggered UIError 42 | return request.send(error.code, { message: error.message, id: error.errorId, error: error.error == null ? error.error : JSON.parse(JSON.stringify(error.error)) }); 43 | 44 | // It'll be extremely weird if real errors will occur there. We should log them. 45 | log.error('Operating error occured in start tracking route', error); 46 | request.send(500, { message: 'Internal error occured', id: 'ERTR500' }); 47 | 48 | return false; 49 | 50 | } 51 | 52 | }); 53 | 54 | // Stop tracking 55 | router.serve('tracking/stop', async request => { 56 | 57 | try { 58 | 59 | // Stopping time tracker 60 | await TaskTracker.stop(); 61 | 62 | // Respond with success code 63 | request.send(200, {}); 64 | 65 | return true; 66 | 67 | } catch (error) { 68 | 69 | // Pass UIErrors directly to renderer 70 | if (error instanceof UIError) { 71 | return request.send(400, { 72 | code: error.code, 73 | message: error.message, 74 | id: error.errorId, 75 | error: error.error == null ? error.error : JSON.parse(JSON.stringify(error.error)), // {error: error.error} means we are passing error that initially triggered UIError 76 | }); 77 | 78 | } 79 | 80 | // It'll be extremely weird if real errors will occur there. We should log them. 81 | log.error('Operating error occured in stop tracking route', error); 82 | request.send(500, { code: 500, message: 'Internal error occured', id: 'ERTR501' }); 83 | 84 | return false; 85 | 86 | } 87 | 88 | }); 89 | 90 | // Activity proof result 91 | router.serve('tracking/activity-proof-result', async req => { 92 | 93 | // Check verification flag existence 94 | if (typeof req.packet.body.verified === 'undefined') 95 | log.error('ERTR003', 'Activity proof result is not defined'); 96 | 97 | // Pass event to TaskTracker 98 | TaskTracker.emit('activity-proof-result', req.packet.body.verified); 99 | 100 | }); 101 | 102 | router.serve('tracking/resume-work-after-inactivity', async () => { 103 | 104 | router.emit('inactivity-modal/resume-work-after-inactivity', {}); 105 | 106 | }); 107 | 108 | // Pass ticks to the frontend 109 | TaskTracker.on('tick', overallTicks => router.emit('tracking/event-tick', { ticks: overallTicks })); 110 | TaskTracker.on('tick-relative', relTicks => router.emit('tracking/event-tick-relative', { ticks: relTicks })); 111 | TaskTracker.on('activity-proof-request', stopTime => router.emit('tracking/activity-proof-request', { stopTime })); 112 | TaskTracker.on('activity-proof-result-accepted', result => { 113 | 114 | router.emit('tracking/activity-proof-result-accepted', { result }); 115 | 116 | }); 117 | TaskTracker.on('activity-proof-result-not-accepted', res => { 118 | 119 | router.emit('tracking/interval-removed', { interval: res }); 120 | router.emit('tracking/activity-proof-result-not-accepted', { totalTicks: res.duration }); 121 | router.emit('misc/update-not-synced-amount', {}); 122 | 123 | }); 124 | TaskTracker.on('started', taskId => router.emit('tracking/event-started', { task: taskId })); 125 | TaskTracker.on('switched', taskId => router.emit('tracking/event-started', { task: taskId })); 126 | TaskTracker.on('stopped', () => { 127 | 128 | router.emit('tracking/event-stopped', {}); 129 | router.emit('misc/update-not-synced-amount', {}); 130 | 131 | }); 132 | TaskTracker.on('interval-pushed', () => router.emit('misc/update-not-synced-amount', {})); 133 | TaskTracker.on('interval-removed', res => { 134 | 135 | router.emit('tracking/interval-removed', { interval: res }); 136 | router.emit('misc/update-not-synced-amount', {}); 137 | 138 | }); 139 | TaskTracker.on('screenshot-capture-failed', () => router.emit('misc/ui-notification', { type: 'error', message: 'Error during screenshot capture' })); 140 | 141 | }; 142 | -------------------------------------------------------------------------------- /app/src/routes/time.js: -------------------------------------------------------------------------------- 1 | const Logger = require('../utils/log'); 2 | const Time = require('../controller/time'); 3 | const { UIError } = require('../utils/errors'); 4 | const auth = require('../base/authentication'); 5 | const { db } = require('../models'); 6 | const OfflineMode = require('../base/offline-mode'); 7 | const TaskTracker = require('../base/task-tracker'); 8 | 9 | const log = new Logger('Router:Time'); 10 | log.debug('Loaded'); 11 | 12 | module.exports = router => { 13 | 14 | // Get overall time from server 15 | router.serve('time/total', async request => { 16 | 17 | try { 18 | 19 | let totalTimeToday = null; 20 | 21 | if (!OfflineMode.enabled) { 22 | 23 | // Starting sync routine 24 | log.debug('Time sync initiated by renderer'); 25 | const currentUser = await auth.getCurrentUser(); 26 | totalTimeToday = await Time.getUserTotalTimeForToday(currentUser.id); 27 | 28 | } else 29 | totalTimeToday = await Time.getLocalTotalTimeForToday(); 30 | 31 | // If tracker is running, add the current interval duration to total time 32 | if (TaskTracker.isActive) 33 | totalTimeToday.time += TaskTracker.ticker.ticks || 0; 34 | 35 | // Returning response 36 | log.debug('Time successfully synced'); 37 | return await request.send(200, { time: totalTimeToday }); 38 | 39 | } catch (error) { 40 | 41 | // Pass UIErrors directly to renderer 42 | if (error instanceof UIError) 43 | // {error: error.error} means we are passing error that initially triggered UIError 44 | return request.send(error.code, { message: error.message, id: error.errorId, error: error.error == null ? error.error : JSON.parse(JSON.stringify(error.error)) }); 45 | 46 | // It'll be extremely weird if real errors will occur there. We should log them. 47 | log.error('Operating error occured in the time total sync route', error); 48 | request.send(500, { message: 'Internal error occured', id: 'ERTT500' }); 49 | 50 | return false; 51 | 52 | } 53 | 54 | }); 55 | 56 | // Getting projects and tasks with their's time spent 57 | // TODO please write the structure that's returned from here, cause it's kinda confusing 58 | router.serve('time/daily-report', async request => { 59 | 60 | // Handle offline mode case 61 | if (OfflineMode.enabled) 62 | return request.send(422, {}); 63 | 64 | // Getting sequelize operators 65 | const { Op } = db.Sequelize; 66 | 67 | // Request tasks been active today from server 68 | let todayTasks = null; 69 | try { 70 | 71 | todayTasks = (await Time.getTasksTimeForToday()); 72 | 73 | } catch (error) { 74 | 75 | // Catch connectivity errors and bypass all other 76 | if (error.request) { 77 | 78 | OfflineMode.trigger(); 79 | return request.send(422, {}); 80 | 81 | } 82 | 83 | throw error; 84 | 85 | } 86 | 87 | // Return special status code if daily report is empty 88 | if (typeof todayTasks === 'undefined') 89 | return request.send(204, {}); 90 | 91 | // Extract identifiers from today tasks 92 | const todayTasksIDs = todayTasks.map(task => task.id); 93 | 94 | // Search and get theese tasks and projects they belong 95 | let localTodayProjects = await db.models.Project.findAll({ 96 | include: [{ 97 | model: db.models.Task, 98 | where: { 99 | externalId: { 100 | [Op.in]: todayTasksIDs, 101 | }, 102 | }, 103 | }], 104 | }); 105 | 106 | const taskTimeByIds = new Map(todayTasks.map(task => [task.id, task.time])); 107 | // Purify projects 108 | localTodayProjects = localTodayProjects.map(project => { 109 | 110 | // Purify project tasks 111 | const tasks = project.dataValues.Tasks.map(task => ({ 112 | id: task.id, 113 | externalId: task.externalId, 114 | name: task.name, 115 | url: task.externalUrl, 116 | trackedTime: taskTimeByIds.get(Number(task.externalId)), 117 | })); 118 | 119 | // Return purified project property 120 | return { name: project.dataValues.name, tasks }; 121 | 122 | }); 123 | 124 | // Dirtiest fix (I've ever seen) for tasks the user doesn't have access to 125 | const localTodayTasksRestQuery = await db.models.Task.findAll({ 126 | 127 | where: { 128 | externalId: { 129 | [Op.in]: todayTasksIDs, 130 | }, 131 | [Op.not]: { 132 | projectId: { 133 | [Op.substring]: '%-%', 134 | }, 135 | }, 136 | }, 137 | }); 138 | 139 | if (localTodayTasksRestQuery !== undefined || localTodayTasksRestQuery.length !== 0) { 140 | 141 | const miscTasks = localTodayTasksRestQuery.map(task => ({ 142 | id: task.dataValues.id, 143 | externalId: task.dataValues.externalId, 144 | name: task.dataValues.name, 145 | url: task.dataValues.externalUrl, 146 | trackedTime: taskTimeByIds.get(Number(task.externalId)), 147 | })); 148 | 149 | localTodayProjects.push({ name: 'Misc', tasks: miscTasks }); 150 | 151 | } 152 | 153 | // Return today tasks object 154 | return request.send(200, { projects: localTodayProjects }); 155 | 156 | }); 157 | 158 | }; 159 | -------------------------------------------------------------------------------- /app/src/routes/translation.js: -------------------------------------------------------------------------------- 1 | const Translation = require('../base/translation'); 2 | const Logger = require('../utils/log'); 3 | 4 | const log = new Logger('Router:Trasnlation'); 5 | log.debug('Loaded'); 6 | 7 | module.exports = router => { 8 | 9 | // Handle translation request 10 | router.serve('translation/get-configuration', r => r.send(200, { configuration: Translation.getConfiguration() })); 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /app/src/routes/user-preferences.js: -------------------------------------------------------------------------------- 1 | const Logger = require('../utils/log'); 2 | const { UIError } = require('../utils/errors'); 3 | const config = require('../base/config'); 4 | const userPreferences = require('../base/user-preferences'); 5 | 6 | const log = new Logger('Router:UserPreferences'); 7 | log.debug('Loaded'); 8 | 9 | module.exports = router => { 10 | 11 | /** 12 | * Handles user preferences bulk export with the structure 13 | */ 14 | router.serve('user-preferences/export-structure', async request => { 15 | 16 | try { 17 | 18 | const preferences = await userPreferences.exportWithStructure(); 19 | return request.send(200, { 20 | preferences, 21 | version: { 22 | package: config.packageId, 23 | number: config.packageVersion, 24 | devMode: config.isDeveloperModeEnabled, 25 | sentry: config.sentry.enabled, 26 | }, 27 | }); 28 | 29 | } catch (error) { 30 | 31 | // Pass UIErrors directly to renderer 32 | if (error instanceof UIError) 33 | // {error: error.error} means we are passing error that initially triggered UIError 34 | return request.send(error.code, { message: error.message, id: error.errorId, error: error.error == null ? error.error : JSON.parse(JSON.stringify(error.error)) }); 35 | 36 | // It'll be extremely weird if real errors will occur there. We should log them. 37 | log.error('Operating error occured in the user preferences structured export', error); 38 | request.send(500, { message: 'Internal error occured', id: 'ERTU500' }); 39 | 40 | return false; 41 | 42 | } 43 | 44 | }); 45 | 46 | /** 47 | * Handles user preferences bulk action save 48 | */ 49 | router.serve('user-preferences/set-many', async request => { 50 | 51 | try { 52 | 53 | // Extract and check preferences list 54 | const { preferences } = request.packet.body; 55 | if (typeof preferences !== 'object') 56 | throw new UIError(400, 'Incorrect preferences container received', 'ERTU400'); 57 | 58 | // Apply changes 59 | userPreferences.setMany(preferences).commit(); 60 | return request.send(200, {}); 61 | 62 | } catch (error) { 63 | 64 | // Pass UIErrors directly to renderer 65 | if (error instanceof UIError) 66 | // {error: error.error} means we are passing error that initially triggered UIError 67 | return request.send(error.code, { message: error.message, id: error.errorId, error: error.error == null ? error.error : JSON.parse(JSON.stringify(error.error)) }); 68 | 69 | // It'll be extremely weird if real errors will occur there. We should log them. 70 | log.error('Operating error occured in the user preferences bulk set', error); 71 | request.send(500, { message: 'Internal error occured', id: 'ERTT500' }); 72 | 73 | return false; 74 | 75 | } 76 | 77 | }); 78 | 79 | }; 80 | -------------------------------------------------------------------------------- /app/src/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "lang": "en", 3 | "title": "English", 4 | "translation": { 5 | "hello": "HEEEEELOOO" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/src/utils/crypto-random.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | /** 4 | * Generates a random integer on defined interval 5 | * @param {Number} min Minimal value of the range 6 | * @param {Number} max Maximal value of the range 7 | * @returns {Number} Random numbers on range [min; max) 8 | */ 9 | module.exports = async (min, max) => { 10 | 11 | // Basic checks for input parameters 12 | if (typeof min !== 'number') 13 | throw new TypeError('Range minimum parameter must be a number'); 14 | if (typeof max !== 'number') 15 | throw new TypeError('Range maximum parameter must be a number'); 16 | if (min < 0) 17 | throw new Error('Range minimum must be a positive number'); 18 | 19 | // Max value for uint32 20 | if (max > 4294967295) 21 | throw new Error('Range maximum cannot be greater than 4294967295'); 22 | 23 | // Getting four random bytes 24 | let randomNumber = await crypto.randomBytes(4); 25 | 26 | // Convert Array into string contains HEX value 27 | randomNumber = randomNumber.reduce((acc, current) => acc + current.toString(16)); 28 | 29 | // Parse HEX string as Number, then convert to values in range from 0 to 1 30 | randomNumber = parseInt(randomNumber, 16) / 10000000000; 31 | 32 | // Perform all the mathematical magic 33 | return Math.floor((randomNumber * (max - min)) + min); 34 | 35 | }; 36 | -------------------------------------------------------------------------------- /app/src/utils/errors.js: -------------------------------------------------------------------------------- 1 | /* Error suppressed because this warning doesn't make much sense */ 2 | /* eslint-disable max-classes-per-file */ 3 | const Sentry = require('./sentry'); 4 | 5 | class UIError extends Error { 6 | 7 | /** 8 | * User Interface error 9 | * @param {String} code Error code 10 | * @param {String} message Error description for humans 11 | * @param {String} errorId Error identifier for T-900s 12 | * @param {Error|null} error Error instance 13 | */ 14 | constructor(code, message, errorId, error = null) { 15 | 16 | // Use behavior from parent Error class 17 | super(message); 18 | 19 | // Capture stack trace 20 | Error.captureStackTrace(this, this.constructor); 21 | 22 | // Set our error name and code 23 | this.name = this.constructor.name; 24 | this.code = code; 25 | this.errorId = errorId; 26 | this.error = error; 27 | 28 | } 29 | 30 | } 31 | 32 | class AppError extends Error { 33 | 34 | /** 35 | * Application error 36 | * @param {String} errorId Error identifier for T-900s 37 | * @param {String} message Error description for humans 38 | */ 39 | constructor(errorId, message) { 40 | 41 | // Use behavior from parent Error class 42 | super(message); 43 | 44 | // Capture stack trace 45 | Error.captureStackTrace(this, this.constructor); 46 | 47 | // Set our error name and code 48 | this.name = this.constructor.name; 49 | this.code = errorId; 50 | 51 | // Push to Sentry 52 | Sentry.captureException(this); 53 | 54 | } 55 | 56 | } 57 | 58 | module.exports = { UIError, AppError }; 59 | -------------------------------------------------------------------------------- /app/src/utils/event-counter.js: -------------------------------------------------------------------------------- 1 | const { powerMonitor } = require('electron'); 2 | 3 | class EventCounter { 4 | 5 | constructor() { 6 | 7 | /** 8 | * Overall interval duration 9 | * @type {Number} 10 | */ 11 | this.intervalDuration = 0; 12 | 13 | /** 14 | * Amount of seconds with detected activity 15 | * @type {Object} 16 | */ 17 | this.activeSeconds = { 18 | keyboard: 0, 19 | mouse: 0, 20 | system: 0, 21 | }; 22 | 23 | /** 24 | * Identifier of the corresponding setInterval 25 | * @type {Number|null} 26 | */ 27 | this.intervalId = null; 28 | 29 | /** 30 | * Identifier of powerMonitor detector setInterval 31 | * @type {Number|null} 32 | */ 33 | this.detectorIntervalId = null; 34 | 35 | /** 36 | * Flag representing mouse activity detection during this second 37 | * @type {Boolean} 38 | */ 39 | this.mouseActiveDuringThisSecond = false; 40 | 41 | /** 42 | * Flag representing keyboard activity detection during this second 43 | * @type {Boolean} 44 | */ 45 | this.keyboardActiveDuringThisSecond = false; 46 | 47 | /** 48 | * Flag representing system activity detection during this second 49 | * @type {Boolean} 50 | */ 51 | this.systemActiveDuringThisSecond = true; 52 | 53 | } 54 | 55 | /** 56 | * Percentage of keyboard activity time 57 | * @type {Number} 58 | */ 59 | get keyboardPercentage() { 60 | 61 | // Avoid Infinity in results 62 | if (this.intervalDuration === 0 || this.activeSeconds.keyboard === 0) 63 | return 0; 64 | 65 | return Math.round(this.activeSeconds.keyboard / (this.intervalDuration / 100)); 66 | 67 | } 68 | 69 | /** 70 | * Percentage of mouse activity time 71 | * @type {Number} 72 | */ 73 | get mousePercentage() { 74 | 75 | // Avoid Infinity in results 76 | if (this.intervalDuration === 0 || this.activeSeconds.mouse === 0) 77 | return 0; 78 | 79 | return Math.round(this.activeSeconds.mouse / (this.intervalDuration / 100)); 80 | 81 | } 82 | 83 | /** 84 | * Percentage of system reported activity time 85 | * @type {Number} 86 | */ 87 | get systemPercentage() { 88 | 89 | // Avoid Infinity in results 90 | if (this.intervalDuration === 0 || this.activeSeconds.system === 0) 91 | return 0; 92 | 93 | return Math.round(this.activeSeconds.system / (this.intervalDuration / 100)); 94 | 95 | } 96 | 97 | /** 98 | * Starts event tracking & counting 99 | */ 100 | start() { 101 | 102 | if (this.intervalId) 103 | throw new Error('This instance of EventCounter is already started'); 104 | 105 | // Set counting interval 106 | this.intervalId = setInterval(() => { 107 | 108 | this.intervalDuration += 1; 109 | 110 | if (this.keyboardActiveDuringThisSecond) 111 | this.activeSeconds.keyboard += 1; 112 | 113 | if (this.mouseActiveDuringThisSecond) 114 | this.activeSeconds.mouse += 1; 115 | 116 | if ( 117 | this.mouseActiveDuringThisSecond 118 | || this.keyboardActiveDuringThisSecond 119 | || this.systemActiveDuringThisSecond 120 | ) 121 | this.activeSeconds.system += 1; 122 | 123 | this.keyboardActiveDuringThisSecond = false; 124 | this.mouseActiveDuringThisSecond = false; 125 | this.systemActiveDuringThisSecond = false; 126 | 127 | }, 1000); 128 | 129 | this.detectorIntervalId = setInterval(() => { 130 | 131 | if (powerMonitor.getSystemIdleTime() === 0) 132 | this.systemActiveDuringThisSecond = true; 133 | 134 | }, 1000); 135 | 136 | } 137 | 138 | /** 139 | * Stops event tracker 140 | */ 141 | stop() { 142 | 143 | if (this.intervalId) 144 | clearInterval(this.intervalId); 145 | 146 | // Resetting counters, flags, and identifiers 147 | this.intervalId = null; 148 | this.keyboardActiveDuringThisSecond = false; 149 | this.mouseActiveDuringThisSecond = false; 150 | this.activeSeconds.keyboard = 0; 151 | this.activeSeconds.mouse = 0; 152 | this.activeSeconds.system = 0; 153 | this.intervalDuration = 0; 154 | 155 | this.keyboardActiveDuringThisSecond = false; 156 | this.mouseActiveDuringThisSecond = false; 157 | this.systemActiveDuringThisSecond = true; 158 | 159 | if (this.detectorIntervalId) { 160 | 161 | clearInterval(this.detectorIntervalId); 162 | this.detectorIntervalId = null; 163 | 164 | } 165 | 166 | } 167 | 168 | /** 169 | * Resets the counters 170 | */ 171 | reset() { 172 | 173 | this.activeSeconds.keyboard = 0; 174 | this.activeSeconds.mouse = 0; 175 | this.activeSeconds.system = 0; 176 | this.intervalDuration = 0; 177 | 178 | this.keyboardActiveDuringThisSecond = false; 179 | this.mouseActiveDuringThisSecond = false; 180 | this.systemActiveDuringThisSecond = true; 181 | 182 | } 183 | 184 | } 185 | 186 | module.exports = new EventCounter(); 187 | -------------------------------------------------------------------------------- /app/src/utils/heartbeat-monitor.js: -------------------------------------------------------------------------------- 1 | const Log = require('./log'); 2 | const Company = require('../base/api').company; 3 | 4 | const log = new Log('HeartbeatMonitor'); 5 | 6 | class HeartbeatMonitor { 7 | 8 | constructor() { 9 | 10 | /** 11 | * Identifier of the heartbeating interval 12 | * @type {Number|null} 13 | */ 14 | this.heartbeat = null; 15 | 16 | /** 17 | * Heartbeat interval in seconds 18 | * @type {Number|null} 19 | */ 20 | this.heartbeatInterval = null; 21 | 22 | } 23 | 24 | /** 25 | * Return current status of the monitor (i.e., is it active) 26 | * @type {Boolean} 27 | */ 28 | get isActive() { 29 | 30 | return this.heartbeat !== null; 31 | 32 | } 33 | 34 | /** 35 | * Fetch a delay between heartbeats from the remote origin 36 | * @async 37 | */ 38 | async fetchHeartbeatInterval() { 39 | 40 | let interval = 0; 41 | try { 42 | 43 | interval = await Company.heartbeatInterval(); 44 | 45 | } catch (err) { 46 | 47 | log.error('HB000', `Cannot get a heartbeat interval from remote, falling back to 30s: ${err}`); 48 | interval = 30; 49 | 50 | } 51 | 52 | // Converting to milliseconds 53 | this.heartbeatInterval = (interval || 30) * 1000; 54 | 55 | } 56 | 57 | /** 58 | * Send a heartbeat request to remote 59 | * @async 60 | * @returns {Promise.} Is beat successfull or not 61 | */ 62 | static async beat() { 63 | 64 | try { 65 | 66 | await Company.heartBeat(); 67 | return true; 68 | 69 | } catch (error) { 70 | 71 | log.error('HB001', `Error occured during heartbeating: ${error}`, true); 72 | return false; 73 | 74 | } 75 | 76 | } 77 | 78 | /** 79 | * Starts the heartbeat monitor 80 | * @async 81 | */ 82 | async start() { 83 | 84 | // Allow only one instance of the monitor at a time 85 | if (this.isActive) 86 | return; 87 | 88 | // Request a heartbeat interval from remote if it is not cached locally yet 89 | if (!this.heartbeatInterval) 90 | await this.fetchHeartbeatInterval(); 91 | 92 | // Setting a new heartbeater, which self-deactivates in case of failure 93 | this.heartbeat = setInterval(async () => await HeartbeatMonitor.beat() || this.stop(), this.heartbeatInterval); 94 | 95 | // Debug 96 | log.debug(`Heartbeat activated! Heartbeat interval is ${this.heartbeatInterval / 1000}s`); 97 | 98 | } 99 | 100 | /** 101 | * Stops the heartbeat monitor 102 | */ 103 | stop() { 104 | 105 | if (!this.heartbeat) 106 | return; 107 | 108 | clearInterval(this.heartbeat); 109 | log.debug('Heartbeat deactivated'); 110 | this.heartbeat = null; 111 | 112 | } 113 | 114 | } 115 | 116 | module.exports = new HeartbeatMonitor(); 117 | -------------------------------------------------------------------------------- /app/src/utils/icons.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const iconsDirectory = path.resolve(__dirname, '..', '..', 'assets', 'icons'); 4 | 5 | module.exports = { 6 | 7 | DEFAULT: `${iconsDirectory}/app/icon.png`, 8 | 9 | tray: { 10 | 11 | IDLE: `${iconsDirectory}/tray/icon-idle.png`, 12 | TRACKING: `${iconsDirectory}/tray/icon-tracking.png`, 13 | LOADING: `${iconsDirectory}/tray/icon-loading.png`, 14 | 15 | }, 16 | 17 | dock: { 18 | 19 | IDLE: `${iconsDirectory}/dock/icon-idle.png`, 20 | TRACKING: `${iconsDirectory}/dock/icon-tracking.png`, 21 | LOADING: `${iconsDirectory}/dock/icon-loading.png`, 22 | 23 | }, 24 | 25 | }; 26 | -------------------------------------------------------------------------------- /app/src/utils/jwt.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Ridiculously simple JWT helper for some basic things 3 | * @version 0.1.0 4 | */ 5 | 6 | class JWTHelper { 7 | 8 | /** 9 | * Validates JWT structure via simple regular expression 10 | * @param {String} token JsonWebToken 11 | * @return {Boolean} Is that token's syntax correct 12 | */ 13 | static checkStructure(token) { 14 | 15 | // Simply validate token using regular expression 16 | return /^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/.test(token); 17 | 18 | } 19 | 20 | /** 21 | * Extracts and parse token body 22 | * @param {String} token JsonWebToken 23 | * @return {Object|null} Token body if succeed, or null if not 24 | */ 25 | static parseBody(token) { 26 | 27 | // Validate token first 28 | if (!this.checkStructure(token)) 29 | return null; 30 | 31 | // Wrap errors 32 | try { 33 | 34 | // Extract body and try to parse as JSON 35 | return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString('utf-8')); 36 | 37 | } catch (error) { 38 | 39 | // Always return null instead of error 40 | return null; 41 | 42 | } 43 | 44 | } 45 | 46 | /** 47 | * Checks token's nbf and exp claims 48 | * @param {String} token JsonWebToken 49 | * @param {Number} [timeDifference] Available time shift before exp in millis 50 | * @return {Boolean} Token's time frame validity 51 | */ 52 | static checkTimeValidity(token, timeDifference = 0) { 53 | 54 | // Parse token 55 | const body = this.parseBody(token); 56 | 57 | // Return null if token parsing failed 58 | if (!body) 59 | return null; 60 | 61 | // Getting current datetime 62 | const currentDatetime = new Date(); 63 | 64 | // Checking nbf (Not Before) claim if present 65 | if (typeof body.nbf !== 'undefined' && (currentDatetime < new Date(body.nbf))) 66 | return false; 67 | 68 | // Checking exp (Expiration Time) claim if present 69 | if (typeof body.exp !== 'undefined' && (currentDatetime - new Date(body.exp)) <= timeDifference) 70 | return false; 71 | 72 | // Everything is fine! 73 | return true; 74 | 75 | } 76 | 77 | } 78 | 79 | module.exports = JWTHelper; 80 | -------------------------------------------------------------------------------- /app/src/utils/keychain.js: -------------------------------------------------------------------------------- 1 | const keytar = require('keytar'); 2 | const Log = require('./log'); 3 | const config = require('../base/config'); 4 | 5 | const logger = new Log('Keychain'); 6 | 7 | /** 8 | * @typedef {Object} SavedCredentials 9 | * @property {String} hostname Remote server URL 10 | * @property {String} email Saved email 11 | * @property {String} password Saved password 12 | */ 13 | 14 | /** 15 | * @typedef {Object} Token 16 | * @property {String} token Token string 17 | * @property {String} type Token type (i.e., bearer) 18 | */ 19 | 20 | /** 21 | * Returns saved credentials from the system keychain 22 | * @return {Promise|Null} Saved credentials 23 | */ 24 | const getSavedCredentials = async () => { 25 | 26 | let fetchedCredentials = ''; 27 | 28 | // Trying to fetch credentials from OS keychain 29 | try { 30 | 31 | fetchedCredentials = await keytar.getPassword(config.credentialsStore.service, 'saved-credentials'); 32 | 33 | } catch (error) { 34 | 35 | // Log this error and return null to keep system working 36 | logger.error(800, error); 37 | return null; 38 | 39 | } 40 | 41 | // Checking is this password exists 42 | if (!fetchedCredentials) 43 | return null; 44 | 45 | 46 | // Parse saved credentials 47 | try { 48 | 49 | fetchedCredentials = JSON.parse(fetchedCredentials); 50 | 51 | } catch (error) { 52 | 53 | // Log this error and return null to keep system working 54 | logger.error(801, error); 55 | return null; 56 | 57 | } 58 | 59 | // Check content of the saved credentials 60 | if (typeof fetchedCredentials.hostname !== 'string') { 61 | 62 | // Log error and return nothing 63 | logger.error('Saved credentials does not contain required "hostname" field', 802); 64 | return null; 65 | 66 | } 67 | 68 | logger.debug('Fetched saved credentials from system keychain'); 69 | return fetchedCredentials; 70 | 71 | }; 72 | 73 | 74 | /** 75 | * Returns saved token from the system keychain 76 | * @return {Promise|Null} Saved token 77 | */ 78 | const getSavedToken = async () => { 79 | 80 | try { 81 | 82 | // Trying to fetch token from OS keychain 83 | let token = await keytar.getPassword(config.credentialsStore.service, 'auth-token'); 84 | 85 | // Returning token or null if it's not exists 86 | if (!token) 87 | return null; 88 | 89 | // Decoding and returning the token 90 | token = JSON.parse(token); 91 | 92 | // Checking fields 93 | if (typeof token.type === 'undefined' || typeof token.token === 'undefined') 94 | throw new Error('Token in system keychain has an incorrect structure'); 95 | 96 | return { token: token.token, tokenType: token.type, tokenExpire: new Date(token.expire) }; 97 | 98 | } catch (error) { 99 | 100 | // Log this error and return null to keep system working 101 | logger.error(800, error); 102 | return null; 103 | 104 | } 105 | 106 | }; 107 | 108 | 109 | /** 110 | * Saves credentials into OS keychain 111 | * @param {Object} credentials Credentials 112 | * @return {Promise|Error} Boolean(true) if succeed, Error if not 113 | */ 114 | const saveCredentials = async credentials => { 115 | 116 | // Trying to save them into system keychain 117 | await keytar.setPassword(config.credentialsStore.service, 'saved-credentials', JSON.stringify(credentials)); 118 | logger.debug(`Saved credentials into system keychain for account: ${credentials.email}`); 119 | return true; 120 | 121 | }; 122 | 123 | /** 124 | * Saves token into keychain 125 | * @param {String} token Token 126 | * @param {String} type Type of the token (i.e., bearer) 127 | * @param {Date} expire Expiration date 128 | * @return {Promise|Error} Boolean(true) if succeed, error if not 129 | */ 130 | const saveToken = async (token, type, expire) => { 131 | 132 | // Checking input arguments 133 | if (typeof token !== 'string' || typeof type !== 'string') 134 | throw new TypeError('Incorrect token to save into keychain'); 135 | 136 | // Trying to save them into system keychain 137 | await keytar.setPassword(config.credentialsStore.service, 'auth-token', JSON.stringify({ type, token, expire })); 138 | logger.debug('Saved token into system keychain'); 139 | return true; 140 | 141 | }; 142 | 143 | /** 144 | * Removes token from system keychain 145 | * @return {Promise} Returns Boolean(true) if succeed, error otherwise 146 | */ 147 | const removeToken = async () => { 148 | 149 | // Removing 150 | keytar.deletePassword(config.credentialsStore.service, 'auth-token'); 151 | logger.debug('Removed token from system keychain'); 152 | return true; 153 | 154 | }; 155 | 156 | 157 | /** 158 | * Removes saved credentials from system keychain 159 | * @return {Promise} Returns Boolean(true) if succeed, error otherwise 160 | */ 161 | const removeSavedCredentials = async () => { 162 | 163 | // Removing 164 | keytar.deletePassword(config.credentialsStore.service, 'saved-credentials'); 165 | logger.debug('Removed saved credentials from system keychain'); 166 | return true; 167 | 168 | }; 169 | 170 | module.exports = { 171 | 172 | getSavedCredentials, 173 | getSavedToken, 174 | saveCredentials, 175 | saveToken, 176 | removeToken, 177 | removeSavedCredentials, 178 | 179 | }; 180 | -------------------------------------------------------------------------------- /app/src/utils/screenshot.js: -------------------------------------------------------------------------------- 1 | const { router } = require('../routes'); 2 | const Log = require('./log'); 3 | const { UIError } = require('./errors'); 4 | const EMPTY_IMAGE = require('../constants/empty-screenshot'); 5 | 6 | const log = new Log('Screenshot'); 7 | 8 | /** 9 | * Mockup for screenshot capture function 10 | * @returns {Buffer} White pseudo-screenshot 11 | */ 12 | const makeScreenshotMockup = () => new Promise(resolve => { 13 | 14 | if (process.env.AT_MOCK_SCR_DELAY !== 'yes') { 15 | 16 | resolve(EMPTY_IMAGE); 17 | return; 18 | 19 | } 20 | 21 | const delay = (Math.random() * Math.random() * 5000); 22 | log.debug(`Delaying capture for ${Math.round(delay)}ms`); 23 | setTimeout(() => resolve(EMPTY_IMAGE), delay); 24 | 25 | }); 26 | 27 | /** 28 | * Makes screenshot 29 | * @async 30 | * @returns {Promise} Captured screenshot 31 | */ 32 | const makeScreenshot = () => Promise.resolve() 33 | 34 | // Requesting screenshots 35 | .then(async () => { 36 | 37 | const timeStart = Date.now(); 38 | const res = await router.request('misc/capture-screenshot', {}); 39 | 40 | // Unsuccessful capture status 41 | if (res.code !== 200) { 42 | 43 | log.error(`ESCR501-${res.code}`, `Error in response from screenshot capture request: ${JSON.stringify(res.body)}`, true); 44 | throw new UIError(res.code, `Error during screenshot capture request: ${res.body}`, `ESCR501-${res.code}`); 45 | 46 | } 47 | 48 | // Capture request doesn't contain any screenshots 49 | if (!res.body.screenshot) 50 | throw new UIError(500, 'No screenshots were captured', 'ESCR502'); 51 | 52 | // Checking screenshot header 53 | if (res.body.screenshot.indexOf('data:image/jpeg;base64,') !== 0) { 54 | 55 | log.error('ESCR503', 'Incorrect screenshot data URL signature received'); 56 | throw new UIError(500, 'Fetched screenshot with incorrect signature', 'ESCR503'); 57 | 58 | } 59 | 60 | // Remove Data URL header and create buffer 61 | const screenshot = Buffer.from(res.body.screenshot.substring(23), 'base64'); 62 | 63 | log.debug(`Captured in ${(Date.now() - timeStart)}ms`); 64 | return screenshot; 65 | 66 | }); 67 | 68 | /** 69 | * Screenshot capturing function 70 | */ 71 | module.exports.makeScreenshot = (process.env.AT_MOCK_SCR === 'yes') ? makeScreenshotMockup : makeScreenshot; 72 | -------------------------------------------------------------------------------- /app/src/utils/sentry.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sentry reporter 3 | * Important notice: https://github.com/getsentry/sentry-electron/issues/92#issuecomment-453534680 4 | */ 5 | 6 | const Sentry = require('@sentry/electron'); 7 | const { init } = require('@sentry/electron/dist/main'); 8 | 9 | const config = require('../base/config'); 10 | 11 | module.exports.isEnabled = Boolean(config.sentry.enabled); 12 | 13 | // Initializes Sentry with configuration 14 | if (module.exports.isEnabled) { 15 | 16 | init({ 17 | dsn: config.sentry.dsn, 18 | release: config.sentry.release, 19 | beforeSend(event) { 20 | 21 | if (module.exports.isEnabled) 22 | return event; 23 | 24 | return null; 25 | 26 | }, 27 | }); 28 | 29 | } 30 | 31 | // Exporting Sentry object 32 | module.exports.Sentry = { Sentry }; 33 | -------------------------------------------------------------------------------- /app/src/utils/ticker.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | 3 | /** 4 | * Ticker 5 | * @extends EventEmitter 6 | */ 7 | class Ticker extends EventEmitter { 8 | 9 | /** 10 | * Current ticks amount 11 | * @return {Number} Amount of tracked ticks 12 | */ 13 | get ticks() { 14 | 15 | return this._ticks; 16 | 17 | } 18 | 19 | /** 20 | * Returns timer current status 21 | * @return {Boolean} True, if timer is active, false otherwise 22 | */ 23 | get status() { 24 | 25 | return (this._active === true); 26 | 27 | } 28 | 29 | /** 30 | * Is timer paused? 31 | * @return {Boolean} True, if so 32 | */ 33 | get paused() { 34 | 35 | return (this._paused === true); 36 | 37 | } 38 | 39 | /** 40 | * Creates ticker 41 | * @param {String} id Value to identify the ticker 42 | */ 43 | constructor() { 44 | 45 | super(); 46 | 47 | /** 48 | * Is ticker active now? 49 | * @type {Boolean} 50 | */ 51 | this._active = false; 52 | 53 | /** 54 | * Amount of ticks counted from last reset 55 | * @type {Number} 56 | */ 57 | this._ticks = 0; 58 | 59 | /** 60 | * Is ticker paused for now? 61 | * @type {Boolean} 62 | */ 63 | this._paused = false; 64 | 65 | /** 66 | * If timer resume is requested? 67 | * @type {Boolean} 68 | */ 69 | this._resumeRequested = false; 70 | 71 | /** 72 | * Unix time of last registered tick 73 | * @type {Number} 74 | */ 75 | this._lastRegisteredTick = 0; 76 | 77 | } 78 | 79 | /** 80 | * Starts the ticker 81 | * @return {Boolean} True, if succeed 82 | */ 83 | start() { 84 | 85 | if (this._active) 86 | return false; 87 | 88 | // Emit event 89 | this.emit('start'); 90 | 91 | // Set activity flag 92 | this._active = true; 93 | 94 | // Create ticker timer instance 95 | this._counter = setInterval(() => { 96 | 97 | // Handling pause feature 98 | if (this._paused || this._resumeRequested) { 99 | 100 | // Register tick and exit 101 | this._lastRegisteredTick = Date.now(); 102 | 103 | // Skip tick iteration if we're still on pause 104 | if (!this._resumeRequested) 105 | return; 106 | 107 | // Unset flags if resume is requested 108 | this._paused = false; 109 | this._resumeRequested = false; 110 | 111 | } 112 | 113 | // Calculate time difference between ticks if there is at least one timer tick behind 114 | if (this._lastRegisteredTick !== 0) { 115 | 116 | // Calculate difference (in case of some system lags and CPU full load) 117 | const diff = Math.round((Date.now() - this._lastRegisteredTick) / 1000); 118 | 119 | // If there is any significant difference - apply the ticks diff 120 | if (diff > 1) 121 | this._ticks += diff; 122 | 123 | } 124 | 125 | // Increment the ticker 126 | this._ticks += 1; 127 | 128 | // Emit event 129 | this.emit('tick'); 130 | 131 | // Update last tick timestamp 132 | this._lastRegisteredTick = Date.now(); 133 | 134 | }, 1000); 135 | 136 | return true; 137 | 138 | } 139 | 140 | /** 141 | * Resets the timer counters 142 | */ 143 | reset() { 144 | 145 | // Reset ticks amount 146 | this._ticks = 0; 147 | 148 | // Reset last tick timestamp 149 | this._lastRegisteredTick = 0; 150 | 151 | // Emitting reset event 152 | this.emit('reset-counter'); 153 | 154 | } 155 | 156 | /** 157 | * Pauses ticker 158 | */ 159 | pause() { 160 | 161 | this.emit('pause'); 162 | this._paused = true; 163 | 164 | } 165 | 166 | /** 167 | * Resuming ticker 168 | */ 169 | resume() { 170 | 171 | this.emit('resume'); 172 | this._resumeRequested = true; 173 | 174 | } 175 | 176 | /** 177 | * Stops ticker 178 | * @param {Boolean} resetSwitcher Switcher to determine whether to reset ticks to zero or not 179 | */ 180 | stop(resetSwitcher = false) { 181 | 182 | // Stops the ticker 183 | if (typeof this._counter !== 'undefined') 184 | clearInterval(this._counter); 185 | 186 | // Unset activity flag 187 | this._active = false; 188 | 189 | // Unset pause flag 190 | this._paused = false; 191 | 192 | // Reset last tick timestamp 193 | this._lastRegisteredTick = 0; 194 | 195 | // Resets the counter if it's requested 196 | if (resetSwitcher) { 197 | 198 | this._ticks = 0; 199 | this.emit('reset-counter'); 200 | 201 | } 202 | 203 | // Emitting stop event 204 | this.emit('stop'); 205 | 206 | } 207 | 208 | } 209 | 210 | module.exports = { Ticker }; 211 | -------------------------------------------------------------------------------- /app/src/utils/time.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bunch of useful time tools 3 | */ 4 | 5 | /** 6 | * Returns today midnight as Date or ISO 8601 string 7 | * @param {Boolean} [formatted=false] Should we return date as ISO 8601 string? 8 | * @return {String|Date} Date object or ISO-formatted string 9 | */ 10 | module.exports.todayMidnight = (formatted = false) => { 11 | 12 | // Get current date 13 | const now = new Date(); 14 | 15 | // Set time to midnight 16 | now.setHours(0, 0, 0, 0); 17 | 18 | // Return as Date or ISO string 19 | return formatted ? now.toISOString() : now; 20 | 21 | }; 22 | 23 | 24 | /** 25 | * Returns end of today as Date or ISO 8601 String 26 | * @param {Boolean} [formatted=false] Should we return date as ISO 8601 string? 27 | * @return {String|Date} Date object or ISO-formatted string 28 | */ 29 | module.exports.todayEOD = (formatted = false) => { 30 | 31 | // Get current date 32 | const now = new Date(); 33 | 34 | // Set time to midnight 35 | now.setHours(23, 59, 59, 999); 36 | 37 | // Return as Date or ISO string 38 | return formatted ? now.toISOString() : now; 39 | 40 | }; 41 | -------------------------------------------------------------------------------- /app/src/utils/translations.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const Log = require('./log'); 4 | 5 | const log = new Log('TranslationsLoader'); 6 | 7 | const resources = {}; 8 | const languages = {}; 9 | 10 | // Load translation files, then parse them 11 | fs 12 | 13 | // Read directory with translations 14 | .readdirSync(path.resolve(__dirname, '..', 'translations')) 15 | 16 | // Filter only JSON files 17 | .filter(f => ((f.indexOf('.') !== 0) && (f.slice(-5) === '.json'))) 18 | 19 | // Parse those files 20 | .forEach(tFileName => { 21 | 22 | // Keeping main execution process in safe 23 | try { 24 | 25 | // Read translation file 26 | let tFileContent = fs.readFileSync(path.resolve(__dirname, '..', 'translations', tFileName), 'utf-8'); 27 | 28 | // Parse it 29 | tFileContent = JSON.parse(tFileContent); 30 | 31 | // Check structure 32 | if (typeof tFileContent.lang !== 'string') 33 | throw new Error('Translation file does not contain language reference'); 34 | if (typeof tFileContent.title !== 'string') 35 | throw new Error('Translation file does not contain full language name'); 36 | if (typeof tFileContent.translation !== 'object') 37 | throw new Error('Translation file does not contain translations'); 38 | 39 | // Check for duplicating language entries 40 | if (typeof resources[tFileContent.lang] !== 'undefined') 41 | throw new Error(`Duplicating dictionary for language: ${tFileContent.lang}`); 42 | 43 | // Push dictionary into buffers 44 | resources[tFileContent.lang] = {}; 45 | resources[tFileContent.lang].translation = { ...tFileContent.translation }; 46 | 47 | // Update supported languages map 48 | languages[tFileContent.title] = tFileContent.lang; 49 | 50 | } catch (error) { 51 | 52 | // Simply log the error 53 | log.error('Error occured translation file parsing', error); 54 | 55 | } 56 | 57 | }); 58 | 59 | // Check amount of available translations 60 | if (Object.keys(resources).length === 0) { 61 | 62 | // Log issue 63 | log.error('TRS00', 'No translations available'); 64 | 65 | // Stop execution 66 | throw new Error('No translation available'); 67 | 68 | } 69 | 70 | module.exports = { resources, languages }; 71 | -------------------------------------------------------------------------------- /docker-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/docker-desktop.png -------------------------------------------------------------------------------- /resources/appx/LargeTile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/appx/LargeTile.png -------------------------------------------------------------------------------- /resources/appx/SmallTile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/appx/SmallTile.png -------------------------------------------------------------------------------- /resources/appx/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/appx/Square150x150Logo.png -------------------------------------------------------------------------------- /resources/appx/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/appx/Square44x44Logo.png -------------------------------------------------------------------------------- /resources/appx/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/appx/StoreLogo.png -------------------------------------------------------------------------------- /resources/appx/Wide310x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/appx/Wide310x150Logo.png -------------------------------------------------------------------------------- /resources/entitlements.mac.inherit.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-jit 7 | 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | 11 | com.apple.security.cs.disable-library-validation 12 | 13 | com.apple.security.automation.apple-events 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /resources/entitlements.mas.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | BN6N4ASB7H.app.cattr 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.inherit 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /resources/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icon.icns -------------------------------------------------------------------------------- /resources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icon.ico -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icon.png -------------------------------------------------------------------------------- /resources/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icons/1024x1024.png -------------------------------------------------------------------------------- /resources/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icons/128x128.png -------------------------------------------------------------------------------- /resources/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icons/16x16.png -------------------------------------------------------------------------------- /resources/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icons/256x256.png -------------------------------------------------------------------------------- /resources/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icons/32x32.png -------------------------------------------------------------------------------- /resources/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icons/48x48.png -------------------------------------------------------------------------------- /resources/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icons/512x512.png -------------------------------------------------------------------------------- /resources/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cattr-app/desktop-application/c9846bde80a519633c4ed24c9657b7f75ac5a0df/resources/icons/64x64.png -------------------------------------------------------------------------------- /tools/artifact-manifest.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const debug = require('debug'); 4 | 5 | debug.enable('cattr:artifact-manifest'); 6 | const log = debug('cattr:artifact-manifest'); 7 | 8 | module.exports = () => { 9 | 10 | /** 11 | * Manifest structure 12 | * @type {Object} 13 | */ 14 | const manifest = { 15 | platform: null, 16 | version: null, 17 | artifacts: [], 18 | }; 19 | 20 | // Read contents of the package.json file 21 | const packageFile = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8')); 22 | 23 | // Copy release version 24 | manifest.version = packageFile.version; 25 | 26 | // Get artifacts list 27 | const artifacts = fs 28 | 29 | // Enumerate files in target/ directory 30 | .readdirSync(path.resolve(__dirname, '..', 'target')) 31 | 32 | // Filter only artifacts containing package version 33 | .filter(el => el.toLowerCase().includes(manifest.version)); 34 | 35 | // Convert platform naming 36 | switch (process.platform) { 37 | 38 | case 'win32': 39 | manifest.platform = 'windows'; 40 | break; 41 | 42 | case 'darwin': 43 | manifest.platform = 'mac'; 44 | break; 45 | 46 | case 'linux': 47 | manifest.platform = 'linux'; 48 | break; 49 | 50 | default: 51 | log('unsupported architecture: %s', process.platform); 52 | return; 53 | 54 | } 55 | 56 | // Iterate over artifacts, push matching into manifest 57 | artifacts.forEach(file => { 58 | 59 | const artifact = { 60 | format: null, 61 | formatHuman: null, 62 | link: null, 63 | }; 64 | 65 | // Artifact file extension (like .exe) 66 | const artifactExtension = path.extname(file.toLowerCase()); 67 | 68 | // Collect macOS artifacts 69 | if (process.platform === 'darwin') { 70 | 71 | // DMG distribution 72 | if (artifactExtension === '.dmg') { 73 | 74 | artifact.format = 'dmg'; 75 | artifact.formatHuman = 'DMG Package'; 76 | 77 | } 78 | 79 | } 80 | 81 | // Collect Windows artifacts 82 | if (process.platform === 'win32') { 83 | 84 | // MSI installer 85 | if (artifactExtension === '.msi') { 86 | 87 | artifact.format = 'msi'; 88 | artifact.formatHuman = 'MSI Installer'; 89 | 90 | } 91 | 92 | // Executable 93 | if (artifactExtension === '.exe') { 94 | 95 | if (file.indexOf('Setup') > -1) { 96 | 97 | // NSIS installer 98 | artifact.format = 'nsis'; 99 | artifact.formatHuman = 'Installer'; 100 | 101 | } else { 102 | 103 | // Portable 104 | artifact.format = 'exe'; 105 | artifact.formatHuman = 'Portable'; 106 | 107 | } 108 | 109 | } 110 | 111 | } 112 | 113 | // Collect Linux artifacts 114 | if (process.platform === 'linux') { 115 | 116 | if (artifactExtension === '.appimage') { 117 | 118 | artifact.format = 'appimage'; 119 | artifact.formatHuman = 'AppImage'; 120 | 121 | } 122 | 123 | if (artifactExtension === '.deb') { 124 | 125 | artifact.format = 'deb'; 126 | artifact.formatHuman = 'Deb Package'; 127 | artifact.link = `https://dl.cattr.app/packages/deb/amd64/${file}`; 128 | 129 | } 130 | 131 | if (artifactExtension === '.gz' && file.toLowerCase().includes('.tar.gz')) { 132 | 133 | artifact.format = 'tgz'; 134 | artifact.formatHuman = 'Tarball'; 135 | 136 | } 137 | 138 | } 139 | 140 | // Push artifact into manifest 141 | if (artifact.format !== null && artifact.formatHuman !== null) { 142 | 143 | log('found %s artifact at %s', artifact.format, file); 144 | if (!artifact.link) 145 | artifact.link = `https://dl.cattr.app/desktop/${manifest.version}/${file}`; 146 | manifest.artifacts.push(artifact); 147 | 148 | } 149 | 150 | }); 151 | 152 | // Build path to manifest folder 153 | const manifestDir = path.resolve(__dirname, '..', 'target', 'manifests'); 154 | 155 | // Create a manifest directory if it is not exists 156 | if (!fs.existsSync(manifestDir)) 157 | fs.mkdirSync(manifestDir); 158 | 159 | // Do nothing if there are no matching artifacts are found 160 | if (manifest.artifacts.length === 0) { 161 | 162 | log('no artifacts found, do not saving manifest'); 163 | return; 164 | 165 | } 166 | 167 | // Saving manifest 168 | fs.writeFileSync(path.resolve(manifestDir, `release-${manifest.platform}.json`), JSON.stringify(manifest), 'utf8'); 169 | log('manifest successfully saved to %s', path.resolve(manifestDir, `release-${manifest.platform}.json`)); 170 | 171 | }; 172 | -------------------------------------------------------------------------------- /tools/clean-development.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { app } = require('electron'); 3 | const keytar = require('keytar'); 4 | const rimraf = require('./rimraf'); 5 | 6 | const appId = 'cattr-develop'; 7 | 8 | (async () => { 9 | 10 | // Cleaning keychain 11 | await keytar.deletePassword(`amazingcat/${appId}`, 'auth-token'); 12 | await keytar.deletePassword(`amazingcat/${appId}`, 'saved-credentials'); 13 | process.stdout.write('Keychain cleaned up\n'); 14 | 15 | // Removing database, logs and config 16 | rimraf.sync(`${path.resolve(app.getPath('appData'), appId)}`, { disableGlob: true }); 17 | process.stdout.write(`Appdata purged ${path.resolve(app.getPath('appData'), appId)}\n`); 18 | process.exit(0); 19 | 20 | })(); 21 | -------------------------------------------------------------------------------- /tools/macos-notarization.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const debug = require('debug'); 4 | require('dotenv').config(); 5 | 6 | debug.enable('cattr:notarization'); 7 | const log = debug('cattr:notarization'); 8 | 9 | /** 10 | * Application ID 11 | * @type {String} 12 | */ 13 | const appId = 'app.cattr'; 14 | 15 | module.exports = async params => { 16 | 17 | if (process.platform !== 'darwin') 18 | return; 19 | 20 | if (process.env.CATTR_NOTARIZE !== 'yes') { 21 | 22 | log('notarization skipped'); 23 | return; 24 | 25 | } 26 | 27 | // eslint-disable-next-line global-require 28 | const electronNotarize = require('electron-notarize'); 29 | 30 | log('notarization triggered'); 31 | 32 | const appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`); 33 | if (!fs.existsSync(appPath)) 34 | throw new Error(`Cannot find application at: ${appPath}`); 35 | 36 | log('notarizing %s found at %s', appId, appPath); 37 | log('take your seats, this might take a while (usually up to 15 minutes)'); 38 | await electronNotarize.notarize({ 39 | appPath, 40 | appBundleId: appId, 41 | appleApiKey: process.env.APPLE_API_KEY, 42 | appleApiIssuer: process.env.APPLE_API_ISSUER, 43 | }); 44 | log('notarization... um.. completed'); 45 | 46 | }; 47 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const mix = require('laravel-mix'); 3 | const SentryWebpackPlugin = require('@sentry/webpack-plugin'); 4 | 5 | mix.setPublicPath('./build'); 6 | mix.disableNotifications(); 7 | mix.webpackConfig({ target: 'electron-renderer', devtool: 'source-map' }); 8 | 9 | /* Build JS */ 10 | if (mix.inProduction) 11 | mix.js('./app/renderer/js/app.js', 'app.js').vue().sourceMaps(); 12 | else 13 | mix.js('./app/renderer/js/app.js', 'app.js').vue(); 14 | 15 | /* Build SASS */ 16 | mix 17 | .sass('./app/renderer/scss/app.scss', 'app.css') 18 | .options({ processCssUrls: false }); 19 | 20 | /* Copy static assets & templates */ 21 | mix 22 | .copy('./app/renderer/app.html', path.resolve(__dirname, 'build', 'app.html')) 23 | .copy('./app/renderer/fonts/**/*', path.resolve(__dirname, 'build', 'fonts')) 24 | .copy('./app/renderer/screen-notie.html', path.resolve(__dirname, 'build', 'screen-notie.html')) 25 | .copy( 26 | './node_modules/element-ui/packages/theme-chalk/lib/fonts/element-icons.woff', 27 | path.resolve(__dirname, 'build', 'fonts', 'element-icons.woff'), 28 | ); 29 | 30 | /* If MAKE_RELEASE flag is set, build renderer in production mode, then submit all the code to Sentry */ 31 | if (mix.inProduction && process.env.MAKE_RELEASE) { 32 | 33 | // eslint-disable-next-line global-require 34 | const packageManifest = require('./package.json'); 35 | 36 | // eslint-disable-next-line global-require 37 | const sentryConfiguration = require('./.sentry.json'); 38 | 39 | mix.webpackConfig({ 40 | plugins: [ 41 | new SentryWebpackPlugin({ 42 | include: 'build', 43 | urlPrefix: 'build/', 44 | ignore: ['mix-manifest.json', 'app.css.map'], 45 | configFile: '.sentry.renderer', 46 | release: `${packageManifest.name}@${packageManifest.version}`, 47 | setCommits: { auto: true }, 48 | url: sentryConfiguration.url, 49 | org: sentryConfiguration.org, 50 | project: sentryConfiguration.frontend.project, 51 | }), 52 | new SentryWebpackPlugin({ 53 | include: 'app/src', 54 | urlPrefix: 'app/src/', 55 | configFile: '.sentry.main', 56 | release: `${packageManifest.name}@${packageManifest.version}`, 57 | setCommits: { auto: true }, 58 | url: sentryConfiguration.url, 59 | org: sentryConfiguration.org, 60 | project: sentryConfiguration.backend.project, 61 | }), 62 | ], 63 | }); 64 | 65 | } 66 | --------------------------------------------------------------------------------