├── .bowerrc ├── .eslintrc.json ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── build ├── background.png └── icon.icns ├── build_env.sh ├── build_files └── Info.plist ├── index.html ├── index2.html ├── main.js ├── package-lock.json ├── package.json ├── src ├── app │ ├── app.js │ ├── app2.js │ ├── css │ │ ├── app.css │ │ ├── app2.css │ │ ├── fonts.css │ │ ├── fonts │ │ │ ├── MaterialIcons.woff │ │ │ ├── Roboto-Bold.woff │ │ │ ├── Roboto-Italic.woff │ │ │ ├── Roboto-Light.woff │ │ │ ├── Roboto-Medium.woff │ │ │ └── Roboto-Regular.woff │ │ └── icons.css │ ├── images │ │ └── tray_icon.png │ ├── js │ │ ├── constants.js │ │ ├── docs.js │ │ ├── evaluators.js │ │ ├── logger.js │ │ ├── open2.js │ │ ├── signals.js │ │ └── utils.js │ └── templates │ │ ├── edit_application.html │ │ ├── edit_condition.html │ │ ├── edit_rule.html │ │ ├── edit_settings.html │ │ ├── list_of_applications.html │ │ └── list_of_rules.html └── bower.json └── tests ├── e2e └── main.test.js ├── test_data └── Applications │ ├── Calculator.app │ └── Contents │ │ └── Info.plist │ ├── Google Chrome.app │ └── Contents │ │ ├── Info.plist │ │ └── Resources │ │ └── app.icns │ └── Safari.app │ └── Contents │ ├── Info.plist │ └── Resources │ └── compass.icns └── unit ├── evaluators.test.js ├── open2.test.js └── utils.test.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "cwd": "./src", 3 | "directory": "bower_components" 4 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "plugins": [ 4 | "standard", 5 | "promise" 6 | ], 7 | "env": { 8 | "browser": true, 9 | "node": true, 10 | "es6": true, 11 | "mocha": true, 12 | "amd": true 13 | }, 14 | "globals": { 15 | "angular": true 16 | }, 17 | "rules": { 18 | "quotes": [2, "single"], 19 | "semi": 0 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thanks for taking the time to contribute! :+1: 4 | When contributing to this repository, please first discuss the change you wish to make via issue, 5 | email, or any other method with the owners of this repository before making a change. 6 | 7 | Please note we have a code of conduct, please follow it in all your interactions with the project. 8 | 9 | ## Pull Request Process 10 | 11 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 12 | build. 13 | 2. Update the README.md with details of changes to the interface, this includes new environment 14 | variables, exposed ports, useful file locations and container parameters. 15 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 16 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 17 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 18 | do not have permission to do that, you may request the second reviewer to merge it for you. 19 | 20 | ## Code of Conduct 21 | 22 | ### Our Pledge 23 | 24 | In the interest of fostering an open and welcoming environment, we as 25 | contributors and maintainers pledge to making participation in our project and 26 | our community a harassment-free experience for everyone, regardless of age, body 27 | size, disability, ethnicity, gender identity and expression, level of experience, 28 | nationality, personal appearance, race, religion, or sexual identity and 29 | orientation. 30 | 31 | ### Our Standards 32 | 33 | Examples of behavior that contributes to creating a positive environment 34 | include: 35 | 36 | * Using welcoming and inclusive language 37 | * Being respectful of differing viewpoints and experiences 38 | * Gracefully accepting constructive criticism 39 | * Focusing on what is best for the community 40 | * Showing empathy towards other community members 41 | 42 | Examples of unacceptable behavior by participants include: 43 | 44 | * The use of sexualized language or imagery and unwelcome sexual attention or 45 | advances 46 | * Trolling, insulting/derogatory comments, and personal or political attacks 47 | * Public or private harassment 48 | * Publishing others' private information, such as a physical or electronic 49 | address, without explicit permission 50 | * Other conduct which could reasonably be considered inappropriate in a 51 | professional setting 52 | 53 | ### Our Responsibilities 54 | 55 | Project maintainers are responsible for clarifying the standards of acceptable 56 | behavior and are expected to take appropriate and fair corrective action in 57 | response to any instances of unacceptable behavior. 58 | 59 | Project maintainers have the right and responsibility to remove, edit, or 60 | reject comments, commits, code, wiki edits, issues, and other contributions 61 | that are not aligned to this Code of Conduct, or to ban temporarily or 62 | permanently any contributor for other behaviors that they deem inappropriate, 63 | threatening, offensive, or harmful. 64 | 65 | ### Scope 66 | 67 | This Code of Conduct applies both within project spaces and in public spaces 68 | when an individual is representing the project or its community. Examples of 69 | representing a project or community include using an official project e-mail 70 | address, posting via an official social media account, or acting as an appointed 71 | representative at an online or offline event. Representation of a project may be 72 | further defined and clarified by project maintainers. 73 | 74 | ### Enforcement 75 | 76 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 77 | reported by contacting the project owner/team. All 78 | complaints will be reviewed and investigated and will result in a response that 79 | is deemed necessary and appropriate to the circumstances. The project team is 80 | obligated to maintain confidentiality with regard to the reporter of an incident. 81 | Further details of specific enforcement policies may be posted separately. 82 | 83 | Project maintainers who do not follow or enforce the Code of Conduct in good 84 | faith may face temporary or permanent repercussions as determined by other 85 | members of the project's leadership. 86 | 87 | ### Attribution 88 | 89 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 90 | available at [http://contributor-covenant.org/version/1/4][version] 91 | 92 | [homepage]: http://contributor-covenant.org 93 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | **Expected behavior** 3 | 4 | **Actual behavior** 5 | 6 | **Steps to reproduce** 7 | 8 | 9 | **Web developer console logs** 10 | 11 | 12 | **Application logs** 13 | 14 | 15 | **Environment** 16 | 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | **What?** 3 | 4 | 5 | **Why?** 6 | 7 | 8 | **How?** 9 | 10 | 11 | **Environment** 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | assets/ 2 | build_app.sh 3 | certificates/ 4 | dev_files/ 5 | dist/ 6 | node_modules/ 7 | scripts/ 8 | src/bower_components/ 9 | 10 | # Created by .ignore support plugin (hsz.mobi) 11 | ### Node template 12 | # Logs 13 | logs 14 | npm-debug.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (http://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | jspm_packages 39 | 40 | # Optional npm cache directory 41 | .npm 42 | 43 | # Optional eslint cache 44 | .eslintcache 45 | 46 | # Optional REPL history 47 | .node_repl_history 48 | 49 | # Yarn Integrity file 50 | .yarn-integrity 51 | 52 | ### JetBrains template 53 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 54 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 55 | 56 | # User-specific stuff: 57 | .idea/tasks.xml 58 | 59 | # Sensitive or high-churn files: 60 | .idea/dataSources/ 61 | .idea/dataSources.ids 62 | .idea/dataSources.xml 63 | .idea/dataSources.local.xml 64 | .idea/sqlDataSources.xml 65 | .idea/dynamic.xml 66 | .idea/uiDesigner.xml 67 | 68 | # Gradle: 69 | .idea/gradle.xml 70 | .idea/libraries 71 | 72 | # Mongo Explorer plugin: 73 | .idea/mongoSettings.xml 74 | 75 | ## File-based project format: 76 | *.iws 77 | 78 | ## Plugin-specific files: 79 | 80 | # IntelliJ 81 | /out/ 82 | 83 | # mpeltonen/sbt-idea plugin 84 | .idea_modules/ 85 | 86 | # JIRA plugin 87 | atlassian-ide-plugin.xml 88 | 89 | # Crashlytics plugin (for Android Studio and IntelliJ) 90 | com_crashlytics_export_strings.xml 91 | crashlytics.properties 92 | crashlytics-build.properties 93 | fabric.properties 94 | ### Example user template 95 | 96 | # IntelliJ project files 97 | .idea 98 | 99 | gen### Example user template template 100 | ### Example user template 101 | 102 | gen### macOS template 103 | *.DS_Store 104 | .AppleDouble 105 | .LSOverride 106 | 107 | # Icon must end with two \r 108 | Icon 109 | 110 | 111 | # Thumbnails 112 | ._* 113 | 114 | # Files that might appear in the root of a volume 115 | .DocumentRevisions-V100 116 | .fseventsd 117 | .Spotlight-V100 118 | .TemporaryItems 119 | .Trashes 120 | .VolumeIcon.icns 121 | .com.apple.timemachine.donotpresent 122 | 123 | # Directories potentially created on remote AFP share 124 | .AppleDB 125 | .AppleDesktop 126 | Network Trash Folder 127 | Temporary Items 128 | .apdisk 129 | 130 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | os: 5 | - osx 6 | env: 7 | matrix: 8 | - TARGET_ARCH=x64 9 | osx_image: xcode7.3 10 | addons: 11 | apt: 12 | packages: 13 | - xvfb 14 | before_install: 15 | - export DISPLAY=:99.0 16 | - Xvfb :99 -screen 0 1440x900x24 > /dev/null 2>&1 & 17 | cache: 18 | directories: 19 | - node_modules 20 | install: 21 | - nvm install 6 22 | - npm install 23 | - npm prune 24 | - npm install -g bower 25 | - bower install --allow-root 26 | - bower prune 27 | script: 28 | - npm test 29 | before_deploy: 30 | - npm run dist-unsigned 31 | deploy: 32 | provider: releases 33 | skip_cleanup: true 34 | all_branches: true 35 | overwrite: true 36 | file_glob: true 37 | file: dist/mac/BrowserDispatcher-* 38 | on: 39 | tags: true 40 | api-key: 41 | secure: TkvCIOlbpJdRYSquCVCtMEzQvJOgBVawMQpdm3KqQuhUU3tkvZnhpeIkjiDOq/Z8y4wOI6N+TpXB59HTrqTs7p2AiciR2MvpLq4cwsice2HhCTRkjJ6LgioI8gM52bj3H8V97KgEcX34FhTXDjFAav8W1mzm6oEtoFEEls96ynNnd66Tqm9czGEQkhQcscJfMtyNr/JPHN6YiozBVly/BjSEAqTMtIpd4V9htfx/zt10ZeHMXx0e/Xv+ZJ1G5KHq/IhLwRicWQ/XUFTzRXxn66pQZO0caYJtoFR8iI7fTa5Cir8z98/bvIaUS4XvYN3Zt1Kw/tIgmdjHN8+oJrpkx0+M3IYdcb/GaC3ketftah8smZmT+hYNixwTjyf8ouv5reAlp4J446F0AJA46RMllbiAWVx0NPuzysCwUExSCKe2FgN32cY8vOHolnodPY8opCiqEVhCKEelOVqCwPU2pVTalcuu1hsT+l2X0/bK0HhqNtkjnNXTh42mDeKw4TSqFA4jHmxUlPnB8rvq08zpee9JET6cdH6Db02LS66bOFQfBXKaoEyQZR8nNeUyk6afSFYaoWwo2Rm7HWwmJIHnAjIRu8p/iL96VZ6NlWCGZLrHDMpU8YkPikWmfw1Ft1DeSWpuORsbYmkBpeCKs889TZQSVbA/XIyNqp/oILAXsgs= 42 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/CONTRIBUTORS.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Andriy Hrytskiv 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/andriyko/browser-dispatcher.svg?branch=master)](https://travis-ci.org/andriyko/browser-dispatcher) 2 | 3 | # Browser Dispatcher 4 | *BrowserDispatcher.app* decides which browser to use when you click on a link outside of your web browser. 5 | Based on your settings, it will display a prompt allowing you to select from a list of browsers or automatically select a browser to open a link with. 6 | 7 | ![BrowserDispatcher.app](http://i.imgur.com/bwjHlb5.png) 8 | 9 | ## Downloads 10 | To download the latest release, see [releases page](https://github.com/andriyko/browser-dispatcher/releases) (macOS only). 11 | 12 | **Note:** the application is not signed by Apple Developer ID. 13 | By default, Mac OS will not open an app from an unidentified developer. 14 | 15 | In the Finder, locate *BrowserDispatcher.app* and Control-click the app icon, then choose Open from the shortcut menu. 16 | Make *BrowserDispatcher.app* default web browser. You can use the app menu `Preferences > Make default browser` or go to `System Preferences > General > Default web browser`. 17 | 18 | The application is an agent app, that is, menu bar app that does not appear in the Dock or Force Quit window. 19 | 20 | ## Running from source 21 | You'll need certain packages installed before you can build and run Browser Dispatcher locally. 22 | 23 | ### Prerequisites 24 | 1. `nodejs >= 6.2` 25 | 26 | Install from your package manager or download from https://nodejs.org 27 | 28 | 2. `npm install -g bower` 29 | 30 | ### Installation 31 | 32 | After installing the prerequisites: 33 | 34 | 1. Clone the git repository from GitHub: 35 | ``` 36 | git clone git@github.com:brave/browser-dispatcher.git 37 | ``` 38 | 39 | 2. Open the working directory: 40 | ``` 41 | cd browser-dispatcher 42 | ``` 43 | 3. Install dependencies: 44 | 45 | ``` 46 | npm install 47 | bower install 48 | ``` 49 | 50 | To start the application in development mode run `npm start` or `electron .`. 51 | 52 | ## Running the tests 53 | 54 | Run unit and end-to-end tests: 55 | ``` 56 | nmp test 57 | ``` 58 | 59 | Run unit tests only: 60 | ``` 61 | npm run test:unit 62 | ``` 63 | 64 | Run end-to-end tests only: 65 | ``` 66 | npm run test:e2e 67 | ``` 68 | 69 | 70 | ## Building the application 71 | 72 | Build without code-signing: 73 | ``` 74 | npm run dist-unsigned 75 | ``` 76 | 77 | Build and code-sign: 78 | ``` 79 | npm run dist 80 | ``` 81 | 82 | ## Built With 83 | 84 | * [Electron](http://electron.atom.io) - The framework for creating native applications with web technologies like JavaScript, HTML, and CSS. 85 | * [Photon](http://photonkit.com) - The fastest way to build beautiful Electron apps using simple HTML and CSS. 86 | 87 | ## Contributing 88 | 89 | Please read [CONTRIBUTING.md](https://github.com/andriyko/browser-dispatcher/blob/master/.github/CONTRIBUTING.md) for details on code of conduct, and the process for submitting pull requests. 90 | 91 | ## Versioning 92 | 93 | For the versions available, see the [tags on this repository](https://github.com/andriyko/browser-dispatcher/tags). 94 | 95 | ## Authors 96 | 97 | * [andriyko](https://github.com/andriyko) 98 | 99 | See also the list of [contributors](CONTRIBUTORS.md) who participated in this project. 100 | 101 | ## License 102 | 103 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 104 | -------------------------------------------------------------------------------- /build/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/build/background.png -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/build/icon.icns -------------------------------------------------------------------------------- /build_env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | npm install 4 | bower install 5 | 6 | npm test 7 | -------------------------------------------------------------------------------- /build_files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSUIElement 6 | 1 7 | 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Browser Dispatcher 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 |
15 |
16 |
17 |
18 | 24 |
25 |
26 |
27 | 33 | 39 | 45 |
46 |
47 |
48 | 52 |
53 |
54 |
55 |
56 | 57 |
58 |
59 | 60 |
61 |
62 | 70 | 71 |
72 |
73 | 75 | 77 |
78 |
79 |
80 |
81 | 123 |
124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /index2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | BrowserDispatcher 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | close 24 | 25 |
26 | 31 | 32 | 33 |
34 |
35 | 36 |
37 | 38 |
39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const iconutil = require('iconutil'); 6 | const electron = require('electron'); 7 | const {app, dialog, ipcMain, Menu, Tray, BrowserWindow} = require('electron'); 8 | const isDev = require('electron-is-dev'); 9 | const Promise = require('bluebird'); 10 | 11 | const {getLogger} = require('./src/app/js/logger'); 12 | const evaluators = require('./src/app/js/evaluators'); 13 | const signals = require('./src/app/js/signals'); 14 | const docs = require('./src/app/js/docs'); 15 | const utils = require('./src/app/js/utils'); 16 | const CONST = require('./src/app/js/constants'); 17 | const open2 = require('./src/app/js/open2'); 18 | 19 | app.setName(CONST.COMMON.APP_NAME); 20 | 21 | // global object for app tray 22 | let appTray = null; 23 | 24 | const logger = getLogger(path.join(app.getPath('userData'), `${CONST.COMMON.APP_NAME}.log`)); 25 | const sqlodm = new docs.CamoWrapper(app.getPath('userData')); 26 | 27 | // Keep a global reference of the window object, if you don't, the window will 28 | // be closed automatically when the JavaScript object is garbage collected. 29 | let mainWindow, secondaryWindow; 30 | 31 | let handleRedirect = (e, url) => { 32 | if (mainWindow && url !== mainWindow.webContents.getURL()) { 33 | e.preventDefault(); 34 | openUrlInDefaultBrowser(url); 35 | } 36 | }; 37 | 38 | function createMainWindow () { 39 | // Only single main window is allowed 40 | if (mainWindow) { 41 | mainWindow.focus(); 42 | return; 43 | } 44 | // Create the browser window. 45 | mainWindow = new BrowserWindow( 46 | { 47 | width: 930, 48 | height: 530, 49 | resizable: true, 50 | show: false 51 | } 52 | ); 53 | 54 | // and load the index.html of the app. 55 | mainWindow.loadURL(`file://${__dirname}/index.html`); 56 | 57 | mainWindow.webContents.on('crashed', (event, killed) => { 58 | logger.error(`The application has crashed. Killed: ${killed}. Event: ${event}`); 59 | let opts = { 60 | type: 'info', 61 | title: 'The application has crashed', 62 | message: 'This process has crashed', 63 | buttons: ['Reload', 'Close'] 64 | }; 65 | dialog.showMessageBox(opts, (index) => { 66 | if (index === 0) { 67 | mainWindow.reload(); 68 | } else { 69 | mainWindow.close(); 70 | } 71 | }); 72 | }); 73 | 74 | mainWindow.webContents.on('will-navigate', handleRedirect); 75 | mainWindow.webContents.on('new-window', handleRedirect); 76 | 77 | mainWindow.on('unresponsive', () => { 78 | logger.error('The application is not responding'); 79 | let opts = { 80 | type: 'info', 81 | title: 'The application is not responding', 82 | message: 'Reload the window?', 83 | buttons: ['Reload', 'Cancel'] 84 | }; 85 | require('dialog').showMessageBox(opts, (index) => { 86 | if (index === 0) { 87 | mainWindow.reload(); 88 | } else { 89 | mainWindow.close(); 90 | } 91 | }); 92 | }); 93 | 94 | mainWindow.on('ready-to-show', () => { 95 | mainWindow.show(); 96 | mainWindow.focus(); 97 | }); 98 | 99 | // Emitted when the window is closed. 100 | mainWindow.on('closed', () => { 101 | // Dereference the window object, usually you would store windows 102 | // in an array if your app supports multi windows, this is the time 103 | // when you should delete the corresponding element. 104 | mainWindow = null; 105 | }); 106 | } 107 | 108 | function createSecondaryWindow (url) { 109 | if (secondaryWindow) { 110 | secondaryWindow.show(); 111 | secondaryWindow.focus(); 112 | } else { 113 | let {x, y} = electron.screen.getCursorScreenPoint(); 114 | secondaryWindow = new BrowserWindow( 115 | { 116 | x: x, 117 | y: y, 118 | closable: true, 119 | alwaysOnTop: true, 120 | movable: false, 121 | resizable: false, 122 | minimizable: false, 123 | maximizable: false, 124 | fullscreenable: false, 125 | show: false, 126 | frame: false, 127 | height: 100, 128 | width: 499, 129 | useContentSize: true, 130 | transparent: true 131 | } 132 | ); 133 | secondaryWindow.loadURL(`file://${__dirname}/index2.html`); 134 | 135 | // TODO add appropriate handler 136 | secondaryWindow.webContents.on('crashed', () => { 137 | logger.error('The app has crashed', {'window': 'createSecondaryWindow'}); 138 | }); 139 | 140 | // TODO add appropriate handler 141 | secondaryWindow.on('unresponsive', () => { 142 | logger.error('The app is unresponsive', {'window': 'createSecondaryWindow'}); 143 | }); 144 | 145 | // Emitted when the window is closed. 146 | secondaryWindow.on('closed', () => { 147 | // Dereference the window object, usually you would store windows 148 | // in an array if your app supports multi windows, this is the time 149 | // when you should delete the corresponding element. 150 | secondaryWindow = null; 151 | }); 152 | } 153 | 154 | secondaryWindow.webContents.on('did-finish-load', () => { 155 | secondaryWindow.webContents.send(signals.response(signals.GENERAL.LOAD_URL), url); 156 | }); 157 | } 158 | 159 | const shouldQuit = app.makeSingleInstance((commandLine, workingDirectory) => { 160 | // Someone tried to run a second instance, we should focus our window. 161 | if (mainWindow) { 162 | if (mainWindow.isMinimized()) { 163 | mainWindow.restore(); 164 | } 165 | mainWindow.focus(); 166 | } 167 | }); 168 | 169 | if (shouldQuit) { 170 | app.quit(); 171 | } 172 | 173 | function buildAppMenu () { 174 | let template = [ 175 | { 176 | label: `${CONST.COMMON.APP_NAME}`, 177 | enabled: mainWindow === null || mainWindow === undefined, 178 | click: () => { 179 | createMainWindow(); 180 | } 181 | }, 182 | { 183 | label: 'Preferences', 184 | submenu: [ 185 | { 186 | label: 'Open at login', 187 | type: 'checkbox', 188 | checked: app.getLoginItemSettings()['openAtLogin'], 189 | click: () => { 190 | if (app.getLoginItemSettings()['openAtLogin']) { 191 | app.setLoginItemSettings({openAtLogin: false, openAsHidden: false}); 192 | } else { 193 | app.setLoginItemSettings({openAtLogin: true, openAsHidden: true}); 194 | } 195 | } 196 | }, 197 | { 198 | label: 'Make default browser', 199 | type: 'checkbox', 200 | checked: app.isDefaultProtocolClient('http'), 201 | click: () => { 202 | if (app.isDefaultProtocolClient('http')) { 203 | app.removeAsDefaultProtocolClient('http'); 204 | app.removeAsDefaultProtocolClient('https'); 205 | } else { 206 | app.setAsDefaultProtocolClient('http'); 207 | app.setAsDefaultProtocolClient('https'); 208 | } 209 | } 210 | } 211 | ] 212 | }, 213 | { 214 | label: 'Edit', 215 | // visible: mainWindow !== null && mainWindow !== undefined, 216 | submenu: [ 217 | { label: 'Undo', accelerator: 'CmdOrCtrl+Z', selector: 'undo:' }, 218 | { label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', selector: 'redo:' }, 219 | { type: 'separator' }, 220 | { label: 'Cut', accelerator: 'CmdOrCtrl+X', selector: 'cut:' }, 221 | { label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' }, 222 | { label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' }, 223 | { label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' } 224 | ] 225 | }, 226 | { 227 | label: 'View', 228 | submenu: [ 229 | { 230 | label: 'Reload', 231 | // visible: mainWindow !== null && mainWindow !== undefined, 232 | accelerator: 'CmdOrCtrl+R', 233 | click: (item, focusedWindow) => { 234 | if (focusedWindow) { 235 | // on reload, start fresh and close any old 236 | // open secondary windows 237 | if (focusedWindow.id === 1) { 238 | BrowserWindow.getAllWindows().forEach((win) => { 239 | if (win.id > 1) { 240 | win.close() 241 | } 242 | }) 243 | } 244 | focusedWindow.reload() 245 | } 246 | } 247 | }, 248 | { 249 | label: 'Show applications selector', 250 | accelerator: 'CmdOrCtrl+L', 251 | click: () => { createSecondaryWindow('http://google.com'); } 252 | }, 253 | { 254 | label: 'Toggle Developer Tools', 255 | // visible: mainWindow !== null && mainWindow !== undefined, 256 | accelerator: 'Alt+Command+I', 257 | click: (item, focusedWindow) => { 258 | // if (focusedWindow && isDev) { 259 | if (focusedWindow) { 260 | focusedWindow.toggleDevTools() 261 | } 262 | } 263 | } 264 | ] 265 | }, 266 | { type: 'separator' }, 267 | { label: 'About', selector: 'orderFrontStandardAboutPanel:', role: 'about' }, 268 | { 269 | label: 'Quit', 270 | accelerator: 'Command+Q', 271 | selector: 'terminate:', 272 | click: () => { 273 | if (mainWindow !== undefined && mainWindow !== null) { 274 | mainWindow.close(); 275 | } 276 | app.quit(); 277 | } 278 | } 279 | ]; 280 | return Menu.buildFromTemplate(template); 281 | } 282 | 283 | function buildTray () { 284 | if (!appTray) { 285 | appTray = new Tray(`${__dirname}/src/app/images/tray_icon.png`); 286 | } 287 | appTray.setToolTip(CONST.COMMON.APP_NAME); 288 | // appTray.setContextMenu(buildContextMenu()); 289 | let menu = buildAppMenu(); 290 | Menu.setApplicationMenu(menu); 291 | appTray.setContextMenu(menu); 292 | } 293 | 294 | function autoLoadBrowsers () { 295 | sqlodm.application.count().then( 296 | count => { 297 | if (count === 0) { 298 | let appsRoot = process.env.BROWSER_DISPATCHER_APPS_ROOT || '/Applications'; 299 | let {results, errors} = utils.getApps(appsRoot); 300 | if (errors.length) { 301 | logger.warn(`There were ${errors.length} errors while fetching the list of applications`); 302 | } 303 | for (let b of results) { 304 | if (b.name !== CONST.COMMON.APP_NAME) { 305 | let application = sqlodm.application.create({ 306 | name: b.name, 307 | display_name: b.display_name, 308 | path: b.path, 309 | icns: b.icns, 310 | executable: b.executable, 311 | identifier: b.identifier, 312 | is_default: (b.name === 'Safari') 313 | }); 314 | application.save().then( 315 | result => { 316 | result = result.toJSON(); 317 | logger.info(`Added application ${result.name}`); 318 | iconutil.toIconset(b.icns, (err, icons) => { 319 | if (!err) { 320 | for (let [k, v] of utils.entries(icons)) { 321 | sqlodm.icon.create({name: k, application: result._id, content: v}).save().then( 322 | ico => { 323 | logger.info(`Added icon ${ico.name} for application ${result.name}`); 324 | }, 325 | error => { 326 | logger.error(`Failed to add icon for the application "${b.name}". Error: `, error); 327 | } 328 | ); 329 | } 330 | } 331 | }); 332 | }, 333 | error => { 334 | logger.error(`Failed to add application "${b.name}". Error: `, error); 335 | } 336 | ); 337 | } 338 | } 339 | } 340 | }, 341 | error => { 342 | logger.error('Failed to count configured applications. Error: ', error); 343 | } 344 | ); 345 | } 346 | 347 | function appReady () { 348 | sqlodm.init().then( 349 | () => { 350 | logger.info('Application started.'); 351 | autoLoadBrowsers(); 352 | require('electron-debug')({showDevTools: isDev}); 353 | }, 354 | error => { 355 | logger.error('Failed to initialize database during application start. Error:', error); 356 | } 357 | ); 358 | 359 | buildTray(); 360 | createMainWindow(); 361 | } 362 | 363 | // This method will be called when Electron has finished 364 | // initialization and is ready to create browser windows. 365 | app.on('ready', appReady); 366 | 367 | // Quit when all windows are closed. 368 | app.on('window-all-closed', () => { 369 | // On OS X it is common for applications and their menu bar 370 | // to stay active until the user quits explicitly with Cmd + Q 371 | if (process.platform !== 'darwin') { 372 | app.quit(); 373 | } 374 | }); 375 | 376 | function openUrl (rule, url) { 377 | let aORb = '-b'; 378 | let options = []; 379 | let application = rule.application.identifier; 380 | 381 | if (rule.use_app_executable) { 382 | aORb = '-a'; 383 | application = rule.application.executable; 384 | } 385 | if (rule.open_new_instance) { 386 | options.push('-n'); 387 | } 388 | if (rule.open_not_foreground) { 389 | options.push('-g'); 390 | } 391 | if (rule.open_fresh) { 392 | options.push('-F'); 393 | } 394 | open2(url, application, aORb, options.join(' '), rule.open_args); 395 | } 396 | 397 | function openUrlInDefaultBrowser (url) { 398 | sqlodm.application.findOne({is_default: true}).then( 399 | application => { 400 | open2(url, application.identifier, '-b'); 401 | }, 402 | error => { 403 | logger.error('[open-url] Failed to get default browser to use. Error: ', error); 404 | createSecondaryWindow(url); 405 | } 406 | ); 407 | } 408 | 409 | app.on('open-file', (event, f) => { 410 | event.preventDefault(); 411 | sqlodm.application.findOne({is_default: true}).then( 412 | application => { 413 | open2(f, application.identifier, '-b'); 414 | }, 415 | error => { 416 | logger.error('Failed to open file. Error: ', error); 417 | createSecondaryWindow(f); 418 | } 419 | ); 420 | }); 421 | 422 | app.on('open-url', (event, url) => { 423 | event.preventDefault(); 424 | sqlodm.prefs.find().then( 425 | results => { 426 | let appStatus = results.find(item => { return item.name === CONST.STATUS.IS_APP_ENABLED }); 427 | let useDefault = results.find(item => { return item.name === CONST.STATUS.IS_USE_DEFAULT }); 428 | if (appStatus.status) { 429 | sqlodm.rule.find({is_active: true}, {populate: true}).then( 430 | rules => { 431 | for (let rule of rules) { 432 | rule.conditions = rule.conditions.filter((cond) => { 433 | return cond.is_active; 434 | }) 435 | } 436 | let rule = evaluators.evaluateRules(rules, url, event); 437 | if (rule) { 438 | openUrl(rule, url); 439 | } else { 440 | if (useDefault.status) { 441 | openUrlInDefaultBrowser(url); 442 | } else { 443 | createSecondaryWindow(url); 444 | } 445 | } 446 | }, 447 | error => { 448 | createSecondaryWindow(url); 449 | logger.error('[open-url] Failed to get the list of rules. Error: ', error); 450 | } 451 | ) 452 | } else { 453 | createSecondaryWindow(url); 454 | } 455 | }, 456 | error => { 457 | logger.error('[open-url] Failed to get configuration. Error: ', error); 458 | createSecondaryWindow(url); 459 | } 460 | ) 461 | }); 462 | 463 | app.on('activate', () => { 464 | // On OS X it's common to re-create a window in the app when the 465 | // dock icon is clicked and there are no other windows open. 466 | if (mainWindow === null) { 467 | createMainWindow(); 468 | } 469 | }); 470 | 471 | // Register listeners 472 | 473 | // Icons 474 | ipcMain.on(signals.request(signals.ICON.READ), (event, arg) => { 475 | return sqlodm.icon.find(arg.query, arg.options).then( 476 | result => { 477 | event.sender.send(signals.response(signals.ICON.READ), result); 478 | }, 479 | error => { 480 | logger.error(`[${signals.ICON.READ}] Error: `, error); 481 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 482 | } 483 | ); 484 | }); 485 | 486 | // Apps/Browsers 487 | ipcMain.on(signals.request(signals.APPLICATION.READ), (event, arg) => { 488 | sqlodm.application.find(arg.query, arg.options).then( 489 | result => { 490 | result.forEach((elem, idx, result) => { 491 | result[idx] = Object.assign({}, elem.toJSON()); 492 | }); 493 | event.sender.send(signals.response(signals.APPLICATION.READ), result); 494 | }, 495 | error => { 496 | logger.error(`[${signals.APPLICATION.READ}] Error: `, error); 497 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 498 | } 499 | ); 500 | }); 501 | 502 | ipcMain.on(signals.request(signals.APPLICATION.UPDATE_DEFAULT), (event, arg) => { 503 | sqlodm.application.findOneAndUpdate({is_default: true}, {is_default: false}, {}).then( 504 | result => { 505 | sqlodm.application.findOneAndUpdate(arg.query, {is_default: true}, {}).then( 506 | res => { 507 | event.sender.send(signals.response(signals.APPLICATION.UPDATE_DEFAULT), res); 508 | logger.info(`Changed default browser from "${result.name}" to "${res.name}"`) 509 | }, 510 | err => { 511 | logger.error(`[${signals.APPLICATION.UPDATE_DEFAULT}] Failed to change default browser. Error: `, err); 512 | event.sender.send(signals.response(signals.GENERAL.ERROR), err); 513 | } 514 | ); 515 | }, 516 | error => { 517 | logger.error(`[${signals.APPLICATION.UPDATE_DEFAULT}] Error: `, error); 518 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 519 | } 520 | ); 521 | }); 522 | 523 | ipcMain.on(signals.request(signals.APPLICATION.READ_ONE), (event, arg) => { 524 | sqlodm.application.findOne(arg.query, arg.options).then( 525 | result => { 526 | event.sender.send(signals.response(signals.APPLICATION.READ_ONE), result); 527 | }, 528 | error => { 529 | logger.error(`[${signals.APPLICATION.READ_ONE}] Error: `, error); 530 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 531 | } 532 | ); 533 | }); 534 | 535 | ipcMain.on(signals.request(signals.APPLICATION.DELETE_ONE), (event, arg) => { 536 | sqlodm.application.deleteOne(arg.query).then( 537 | () => { 538 | Promise.all([ 539 | sqlodm.icon.deleteMany({application: arg.query._id}), 540 | sqlodm.rule.deleteMany({application: arg.query._id}) 541 | ]).then( 542 | result => { 543 | event.sender.send(signals.response(signals.APPLICATION.DELETE_ONE), result); 544 | logger.info(`Removed application: ${arg.query._id}`); 545 | }, 546 | error => { 547 | logger.error(`[${signals.APPLICATION.DELETE_ONE}] Failed to remove application ${arg.query._id}. Error: `, error); 548 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 549 | } 550 | ) 551 | }, 552 | error => { 553 | logger.error(`[${signals.APPLICATION.DELETE_ONE}] Error: `, error); 554 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 555 | } 556 | ); 557 | }); 558 | 559 | ipcMain.on(signals.request(signals.APPLICATION.CREATE_ONE), (event, arg) => { 560 | sqlodm.application.create(arg.values).save().then( 561 | result => { 562 | result = Object.assign({}, result.toJSON()); 563 | 564 | iconutil.toIconset(result.icns, 565 | (err, icons) => { 566 | if (!err) { 567 | for (let [k, v] of utils.entries(icons)) { 568 | sqlodm.icon.create({name: k, application: result._id, content: v}).save().then( 569 | icon => { 570 | logger.info(`Added icon ${icon.name} for application ${result.name}`); 571 | }, 572 | error => { 573 | logger.error(`[${signals.APPLICATION.CREATE_ONE}] Failed to add icon for application. Error: `, error); 574 | } 575 | ); 576 | } 577 | event.sender.send(signals.response(signals.APPLICATION.CREATE_ONE), result); 578 | logger.info(`Added application: ${result.name}`); 579 | } else { 580 | event.sender.send(signals.response(signals.APPLICATION.CREATE_ONE), result); 581 | } 582 | } 583 | ); 584 | }, 585 | error => { 586 | logger.error(`[${signals.APPLICATION.CREATE_ONE}]. Error: `, error); 587 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 588 | } 589 | ) 590 | }); 591 | 592 | ipcMain.on(signals.request(signals.APPLICATION.UPDATE_ONE), (event, arg) => { 593 | let values = sqlodm.application._fromData(arg.values).toJSON(); 594 | sqlodm.application.findOneAndUpdate(arg.query, values).then( 595 | result => { 596 | result = Object.assign({}, result.toJSON()); 597 | event.sender.send(signals.response(signals.APPLICATION.UPDATE_ONE), result); 598 | logger.info(`Updated application: ${result.name}`); 599 | }, 600 | error => { 601 | logger.error(`[${signals.APPLICATION.UPDATE_ONE}]. Error: `, error); 602 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 603 | } 604 | ); 605 | }); 606 | 607 | ipcMain.on(signals.request(signals.APPLICATION.UPDATE), (event, arg) => { 608 | sqlodm.application.find(arg.query).then( 609 | result => { 610 | result.forEach(item => { 611 | Object.assign(item, arg.values); 612 | item.save(); 613 | }); 614 | event.sender.send(signals.response(signals.APPLICATION.UPDATE), result); 615 | }, 616 | error => { 617 | logger.error(`[${signals.APPLICATION.UPDATE}]. Error: `, error); 618 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 619 | } 620 | ); 621 | }); 622 | 623 | // Settings/Prefs 624 | ipcMain.on(signals.request(signals.CONFIG.READ_STATUS), (event, arg) => { 625 | sqlodm.prefs.findOne(arg.query).then( 626 | result => { 627 | result = Object.assign({}, result.toJSON()); 628 | event.sender.send(signals.response(signals.CONFIG.READ_STATUS), result); 629 | }, 630 | error => { 631 | logger.error(`[${signals.CONFIG.READ_STATUS}]. Error: `, error); 632 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 633 | } 634 | ); 635 | }); 636 | 637 | ipcMain.on(signals.request(signals.CONFIG.UPDATE_STATUS), (event, arg) => { 638 | sqlodm.prefs.findOneAndUpdate(arg.query, arg.values).then( 639 | result => { 640 | result = Object.assign({}, result.toJSON()); 641 | event.sender.send(signals.response(signals.CONFIG.UPDATE_STATUS), result); 642 | logger.info(result.status ? 'Enabled app' : 'Disabled app'); 643 | }, 644 | error => { 645 | logger.error(`[${signals.CONFIG.UPDATE_STATUS}]. Error: `, error); 646 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 647 | } 648 | ); 649 | }); 650 | 651 | ipcMain.on(signals.request(signals.CONFIG.READ_IS_USE_DEFAULT), (event, arg) => { 652 | sqlodm.prefs.findOne(arg.query).then( 653 | result => { 654 | result = Object.assign({}, result.toJSON()); 655 | event.sender.send(signals.response(signals.CONFIG.READ_IS_USE_DEFAULT), result); 656 | }, 657 | error => { 658 | logger.error(`[${signals.CONFIG.READ_IS_USE_DEFAULT}]. Error: `, error); 659 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 660 | } 661 | ); 662 | }); 663 | 664 | ipcMain.on(signals.request(signals.CONFIG.UPDATE_IS_USE_DEFAULT), (event, arg) => { 665 | sqlodm.prefs.findOneAndUpdate(arg.query, arg.values).then( 666 | result => { 667 | result = Object.assign({}, result.toJSON()); 668 | event.sender.send(signals.response(signals.CONFIG.UPDATE_IS_USE_DEFAULT), result); 669 | let action = result.status ? 'Enabled' : 'Disabled'; 670 | logger.info(`${action} usage of default browser if none of the rules match an URL`); 671 | }, 672 | error => { 673 | logger.error(`[${signals.CONFIG.UPDATE_IS_USE_DEFAULT}]. Error: `, error); 674 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 675 | } 676 | ); 677 | }); 678 | 679 | // Rules 680 | ipcMain.on(signals.request(signals.RULE.READ), (event, arg) => { 681 | sqlodm.rule.find(arg.query, arg.options).then( 682 | result => { 683 | result.forEach((elem, idx, result) => { 684 | result[idx] = elem.toJSON(); 685 | }); 686 | event.sender.send(signals.response(signals.RULE.READ), result); 687 | }, 688 | error => { 689 | logger.error(`[${signals.RULE.READ}]. Error: `, error); 690 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 691 | } 692 | ); 693 | }); 694 | 695 | ipcMain.on(signals.request(signals.RULE.CREATE_ONE), (event, arg) => { 696 | sqlodm.rule.create(arg.values).preValidate().save().then( 697 | result => { 698 | result = Object.assign({}, result.toJSON()); 699 | event.sender.send(signals.response(signals.RULE.CREATE_ONE), result); 700 | logger.info(`Added rule: ${result._id}`); 701 | }, 702 | error => { 703 | logger.error(`[${signals.RULE.CREATE_ONE}]. Error: `, error); 704 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 705 | } 706 | ); 707 | }); 708 | 709 | ipcMain.on(signals.request(signals.RULE.UPDATE_ONE), (event, arg) => { 710 | let values = sqlodm.rule.create(arg.values).preValidate().toJSON(); 711 | sqlodm.rule.findOneAndUpdate(arg.query, values, arg.options).then( 712 | result => { 713 | result = Object.assign({}, result.toJSON()); 714 | event.sender.send(signals.response(signals.RULE.UPDATE_ONE), result); 715 | logger.info(`Updated rule: ${result._id}`); 716 | }, 717 | error => { 718 | logger.error(`[${signals.RULE.UPDATE_ONE}]. Error: `, error); 719 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 720 | } 721 | ); 722 | }); 723 | 724 | ipcMain.on(signals.request(signals.RULE.DELETE_ONE), (event, arg) => { 725 | sqlodm.rule.deleteOne(arg.query).then( 726 | result => { 727 | event.sender.send(signals.response(signals.RULE.DELETE_ONE), result); 728 | logger.info(`Deleted rule: ${result._id}`); 729 | }, 730 | error => { 731 | logger.error(`[${signals.RULE.DELETE_ONE}]. Error: `, error); 732 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 733 | } 734 | ); 735 | }); 736 | 737 | ipcMain.on(signals.request(signals.RULE.CLEAR), (event, arg) => { 738 | sqlodm.rule.clearCollection().then( 739 | result => { 740 | event.sender.send(signals.response(signals.RULE.CLEAR), result); 741 | }, 742 | error => { 743 | logger.error(`[${signals.RULE.CLEAR}]. Error: `, error); 744 | event.sender.send(signals.response(signals.GENERAL.ERROR), error.toString()); 745 | } 746 | ); 747 | }); 748 | 749 | ipcMain.on(signals.request(signals.CONFIG.RESET_ALL), (event, arg) => { 750 | let userDataPathDatabases = path.join(app.getPath('userData'), 'databases'); 751 | for (let fpath of [ 752 | path.join(userDataPathDatabases, 'applications.db'), 753 | path.join(userDataPathDatabases, 'icons.db'), 754 | path.join(userDataPathDatabases, 'rules.db'), 755 | path.join(userDataPathDatabases, 'settings.db') 756 | ]) { 757 | if (fs.existsSync(fpath) && fs.statSync(fpath).isFile()) { 758 | fs.unlinkSync(fpath); 759 | } 760 | } 761 | app.relaunch(); 762 | app.exit(0); 763 | event.returnValue = 'done'; 764 | }); 765 | 766 | ipcMain.on(signals.request(signals.GENERAL.TEST_URL), (event, arg) => { 767 | let result = evaluators.evaluateRules(arg.rules, arg.url, event); 768 | if (result) { 769 | result = Object.assign({}, result); 770 | } 771 | event.sender.send(signals.response(signals.GENERAL.TEST_URL), result); 772 | }); 773 | 774 | ipcMain.on(signals.request(signals.GENERAL.SHOW_MAN_PAGE), (event, arg) => { 775 | require('child_process').exec('man -t open | open -f -a Preview'); 776 | event.sender.send(signals.response(signals.GENERAL.SHOW_MAN_PAGE), {}); 777 | }); 778 | 779 | // App2 signal handlers 780 | ipcMain.on(signals.request(signals.GENERAL.OPEN_URL), (event, arg) => { 781 | open2(arg.url, arg.application.identifier, '-b'); 782 | secondaryWindow.close(); 783 | // secondaryWindow.destroy(); 784 | }); 785 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BrowserDispatcher", 3 | "productName": "BrowserDispatcher", 4 | "version": "0.1.5", 5 | "description": "Sends link to the right browser depending on a set of predefined rules", 6 | "main": "main.js", 7 | "author": "Andriy Hrytskiv", 8 | "license": "MIT", 9 | "private": true, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/andriyko/browser-dispatcher.git" 13 | }, 14 | "keywords": [ 15 | "Electron", 16 | "Browser" 17 | ], 18 | "bugs": { 19 | "url": "https://github.com/andriyko/browser-dispatcher/issues" 20 | }, 21 | "homepage": "https://github.com/andriyko/browser-dispatcher", 22 | "dependencies": { 23 | "bluebird": "^3.4.7", 24 | "camo": "^0.12.3", 25 | "electron-debug": "^1.1.0", 26 | "electron-is-dev": "^0.1.2", 27 | "glob": "^7.1.1", 28 | "iconutil": "^1.0.1", 29 | "nedb": "^1.8.0", 30 | "plist": "^2.0.1", 31 | "winston": "^2.3.0" 32 | }, 33 | "devDependencies": { 34 | "babel-cli": "^6.18.0", 35 | "babel-preset-electron": "^0.37.8", 36 | "babel-preset-es2015": "^6.18.0", 37 | "chai": "^3.5.0", 38 | "chai-as-promised": "^6.0.0", 39 | "devtron": "^1.4.0", 40 | "electron": "^1.4.15", 41 | "electron-builder": "^11.7.0", 42 | "electron-rebuild": "^1.5.6", 43 | "eslint": "^3.15.0", 44 | "eslint-config-standard": "^6.2.1", 45 | "eslint-plugin-promise": "^3.4.1", 46 | "eslint-plugin-standard": "^2.0.1", 47 | "mocha": "^3.2.0", 48 | "mock-require": "^2.0.1", 49 | "proxyquire": "^1.7.11", 50 | "spectron": "^3.6.0" 51 | }, 52 | "scripts": { 53 | "start": "electron . --enable-logging", 54 | "lint": "./node_modules/.bin/eslint src/app/ && ./node_modules/.bin/eslint tests && ./node_modules/.bin/eslint main.js", 55 | "test:unit": "./node_modules/.bin/mocha tests/unit --recursive", 56 | "test:e2e": "./node_modules/.bin/mocha tests/e2e --recursive", 57 | "test": "npm run lint && npm run test:unit && npm run test:e2e", 58 | "clean": "rm -rf ./dist", 59 | "dist-unsigned": "export CSC_IDENTITY_AUTO_DISCOVERY=false && npm run clean && npm prune && bower prune && node_modules/.bin/build --mac", 60 | "dist": "npm run clean && npm prune && bower prune && node_modules/.bin/build --mac" 61 | }, 62 | "build": { 63 | "appId": "com.electron.browserdispatcher", 64 | "mac": { 65 | "category": "public.app-category.productivity" 66 | }, 67 | "extend-info": "build_files/Info.plist", 68 | "files": [ 69 | "**/*", 70 | "!.*", 71 | "!build_env.sh", 72 | "!build_app.sh", 73 | "!package.json", 74 | "!README.md", 75 | "!CONTRIBUTORS.md", 76 | "!src/bower.json", 77 | "!src/bower_components${/*}", 78 | "!assets${/*}", 79 | "!build_files${/*}", 80 | "!certificates${/*}", 81 | "!dev_files${/*}", 82 | "!built${/*}", 83 | "!dist{/*}", 84 | "!scripts${/*}", 85 | "!tests${/*}", 86 | "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,test,__tests__,tests,powered-test,example,examples,*.d.ts}", 87 | "!**/node_modules/.bin", 88 | "!**/*.{o,hprof,orig,pyc,pyo,rbc}", 89 | "!**/._*", 90 | "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,thumbs.db,.gitignore,.gitattributes,.editorconfig,.flowconfig,.yarn-metadata.json,.idea,appveyor.yml,.travis.yml,circle.yml,npm-debug.log,.nyc_output,yarn.lock,.yarn-integrity}", 91 | "src/bower_components/angular/angular.min.js", 92 | "src/bower_components/angular-sanitize/angular-sanitize.min.js", 93 | "src/bower_components/angular-animate/angular-animate.min.js", 94 | "src/bower_components/angular-aria/angular-aria.min.js", 95 | "src/bower_components/angular-messages/angular-messages.min.js", 96 | "src/bower_components/angular-material/angular-material.min.js", 97 | "src/bower_components/angular-material/angular-material.min.css", 98 | "src/bower_components/flexboxgrid/dist/flexboxgrid.min.css", 99 | "src/bower_components/photon/dist/css/photon.min.css", 100 | "src/bower_components/photon/dist/fonts/*" 101 | ], 102 | "protocols": [ 103 | { 104 | "name": "Web site URL", 105 | "role": "Viewer", 106 | "schemes": [ 107 | "http", 108 | "https" 109 | ] 110 | } 111 | ] 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/app/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {remote, ipcRenderer} = require('electron'); 4 | const {dialog} = require('electron').remote; 5 | 6 | const signals = require('./src/app/js/signals'); 7 | const utils = require('./src/app/js/utils'); 8 | const evaluators = require('./src/app/js/evaluators'); 9 | const CONST = require('./src/app/js/constants'); 10 | 11 | const myApp = angular.module('myApp', ['ngSanitize']); 12 | 13 | myApp.run(function ($rootScope) { 14 | $rootScope.ctx = { 15 | _settings: 'settings', 16 | _rules: 'rules', 17 | _apps: 'apps', 18 | _ctx: 'rules', 19 | _switchCtx: function (ctx) { 20 | this._ctx = ctx; 21 | }, 22 | _isCtx: function (ctx) { 23 | return this._ctx === ctx; 24 | }, 25 | isSettingsView: function () { 26 | return this._isCtx(this._settings); 27 | }, 28 | switchToSettingsView: function () { 29 | this._switchCtx(this._settings); 30 | }, 31 | isRulesView: function () { 32 | return this._isCtx(this._rules); 33 | }, 34 | switchToRulesView: function () { 35 | this._switchCtx(this._rules); 36 | }, 37 | isAppsView: function () { 38 | return this._isCtx(this._apps); 39 | }, 40 | switchToAppsView: function () { 41 | this._switchCtx(this._apps); 42 | } 43 | }; 44 | $rootScope.SIGNALS = angular.copy(signals); 45 | }); 46 | 47 | myApp.service('rulesTester', function ($rootScope) { 48 | this.data = { 49 | isTesting: false, 50 | url: null, 51 | result: null 52 | }; 53 | 54 | ipcRenderer.on(signals.response(signals.GENERAL.TEST_URL), (event, arg) => { 55 | this.data.result = arg; 56 | $rootScope.$apply(); 57 | }); 58 | 59 | this.reset = function () { 60 | this.data.isTesting = false; 61 | this.data.url = null; 62 | this.data.result = null; 63 | }; 64 | 65 | this.runTest = function (rules) { 66 | this.data.isTesting = true; 67 | ipcRenderer.send( 68 | signals.request(signals.GENERAL.TEST_URL), {rules: rules, url: this.data.url} 69 | ); 70 | } 71 | }); 72 | 73 | myApp.service('storage', function ($rootScope, DEFAULT_CONDITION) { 74 | this.data = { 75 | rules: [], 76 | applications: [], 77 | icons: {}, 78 | settings: { 79 | is_app_enabled: false, 80 | use_default: false, 81 | default_application: {} 82 | }, 83 | rule: {}, 84 | application: {} 85 | }; 86 | 87 | this.loadRule = function (data) { 88 | if (angular.equals({}, data)) { 89 | // This is new rule 90 | data = { 91 | name: `New rule ${this.data.rules.length + 1}`, 92 | is_active: true, 93 | open_new_instance: false, 94 | open_not_foreground: false, 95 | open_fresh: false, 96 | open_args: '', 97 | application: this.data.applications.find(function (application) { 98 | return application.is_default === true; 99 | }), 100 | operator: CONST.RULE_OPERATOR.ANY, 101 | conditions: [angular.copy(DEFAULT_CONDITION)] 102 | }; 103 | // We do not need any data from previous rule 104 | for (let k of Object.keys(this.data.rule)) { 105 | delete this.data.rule[k]; 106 | } 107 | } 108 | // Keep a reference to the object! 109 | Object.assign(this.data.rule, angular.copy(data)); 110 | }; 111 | 112 | this.loadApplication = function (data) { 113 | if (angular.equals({}, data)) { 114 | data = { 115 | name: '', 116 | path: '', 117 | icns: '', 118 | display_name: '', 119 | executable: '', 120 | identifier: '', 121 | is_active: true, 122 | is_default: false 123 | }; 124 | // We do not need any data from previous rule 125 | for (let k of Object.keys(this.data.application)) { 126 | delete this.data.application[k]; 127 | } 128 | } 129 | // Keep a reference to the object! 130 | Object.assign(this.data.application, angular.copy(data)); 131 | }; 132 | 133 | // Settings: listeners 134 | ipcRenderer.on(signals.response(signals.CONFIG.READ_STATUS), (event, arg) => { 135 | this.data.settings.is_app_enabled = arg.status; 136 | $rootScope.$apply(); 137 | }); 138 | 139 | ipcRenderer.on(signals.response(signals.CONFIG.UPDATE_STATUS), (event, arg) => { 140 | this.data.settings.is_app_enabled = arg.status; 141 | $rootScope.$apply(); 142 | }); 143 | 144 | ipcRenderer.on(signals.response(signals.CONFIG.READ_IS_USE_DEFAULT), (event, arg) => { 145 | this.data.settings.use_default = arg.status; 146 | $rootScope.$apply(); 147 | }); 148 | 149 | ipcRenderer.on(signals.response(signals.CONFIG.UPDATE_IS_USE_DEFAULT), (event, arg) => { 150 | this.data.settings.use_default = arg.status; 151 | $rootScope.$apply(); 152 | }); 153 | 154 | // Settings: event emitters 155 | this.getAppStatus = function () { 156 | ipcRenderer.send( 157 | signals.request(signals.CONFIG.READ_STATUS), 158 | {query: {name: CONST.STATUS.IS_APP_ENABLED}} 159 | ); 160 | }; 161 | 162 | this.saveAppStatus = function () { 163 | ipcRenderer.send( 164 | signals.request(signals.CONFIG.UPDATE_STATUS), 165 | {query: {name: CONST.STATUS.IS_APP_ENABLED}, values: {status: this.data.settings.is_app_enabled}} 166 | ); 167 | }; 168 | 169 | this.getIsUseDefault = function () { 170 | ipcRenderer.send( 171 | signals.request(signals.CONFIG.READ_IS_USE_DEFAULT), 172 | {query: {name: CONST.STATUS.IS_USE_DEFAULT}} 173 | ); 174 | }; 175 | 176 | this.saveIsUseDefault = function () { 177 | ipcRenderer.send( 178 | signals.request(signals.CONFIG.UPDATE_IS_USE_DEFAULT), 179 | {query: {name: CONST.STATUS.IS_USE_DEFAULT}, values: {status: this.data.settings.use_default}} 180 | ); 181 | }; 182 | 183 | // Icons: listeners 184 | ipcRenderer.on(signals.response(signals.ICON.READ), (event, arg) => { 185 | let sizeToName = { 186 | 'icon_16x16.png': 'mini', 187 | 'icon_32x32.png': 'small', 188 | 'icon_32x32@2x.png': 'medium', 189 | 'icon_128x128.png': 'large' 190 | }; 191 | arg.forEach(result => { 192 | if (!this.data.icons[result.application]) { 193 | this.data.icons[result.application] = {}; 194 | } 195 | let alias = sizeToName[result.name]; 196 | if (alias) { 197 | this.data.icons[result.application][alias] = result.content; 198 | } 199 | }); 200 | $rootScope.$apply(); 201 | }); 202 | 203 | // Icons: event emitters 204 | this.getIcons = function (query) { 205 | ipcRenderer.send(signals.request(signals.ICON.READ), {query: query, options: {populate: false}}); 206 | }; 207 | 208 | // Rules: listeners 209 | ipcRenderer.on(signals.response(signals.RULE.READ), (event, arg) => { 210 | this.data.rules.splice(0, this.data.rules.length); // keep the reference to the array 211 | for (let rule of arg) { 212 | this.data.rules.push(rule); 213 | } 214 | $rootScope.$apply(); 215 | }); 216 | 217 | ipcRenderer.on(signals.response(signals.RULE.CREATE_ONE), (event, arg) => { 218 | this.getRules(); 219 | }); 220 | 221 | ipcRenderer.on(signals.response(signals.RULE.UPDATE_ONE), (event, arg) => { 222 | this.getRules(); 223 | }); 224 | 225 | ipcRenderer.on(signals.response(signals.RULE.DELETE_ONE), (event, arg) => { 226 | this.getRules(); 227 | }); 228 | 229 | ipcRenderer.on(signals.response(signals.RULE.CLEAR), (event, arg) => { 230 | this.getRules(); 231 | }); 232 | 233 | // Rules: event emitters 234 | this.getRules = function () { 235 | ipcRenderer.send(signals.request(signals.RULE.READ), {query: {}, options: {populate: true}}); 236 | }; 237 | 238 | this.createRule = function (data) { 239 | ipcRenderer.send(signals.request(signals.RULE.CREATE_ONE), {values: data || this.data.rule}); 240 | }; 241 | 242 | this.updateRule = function () { 243 | ipcRenderer.send( 244 | signals.request(signals.RULE.UPDATE_ONE), 245 | {query: {_id: this.data.rule._id}, values: this.data.rule} 246 | ); 247 | }; 248 | 249 | this.deleteRule = function (rule) { 250 | let query = rule && rule._id ? {_id: rule._id} : {_id: this.data.rule._id}; 251 | ipcRenderer.send(signals.request(signals.RULE.DELETE_ONE), {query: query} 252 | ); 253 | }; 254 | 255 | // Applications: listeners 256 | ipcRenderer.on(signals.response(signals.APPLICATION.CREATE_ONE), (event, arg) => { 257 | this.getApplications(); 258 | this.getIcons(); 259 | $rootScope.$apply(); 260 | }); 261 | 262 | ipcRenderer.on(signals.response(signals.APPLICATION.UPDATE_DEFAULT), (event, arg) => { 263 | this.getApplications(); 264 | this.getIcons(); 265 | this.data.settings.default_application = arg; 266 | $rootScope.$apply(); 267 | }); 268 | 269 | ipcRenderer.on(signals.response(signals.APPLICATION.UPDATE_ONE), (event, arg) => { 270 | this.getApplications(); 271 | }); 272 | 273 | ipcRenderer.on(signals.response(signals.APPLICATION.UPDATE), (event, arg) => { 274 | this.getApplications(); 275 | }); 276 | 277 | ipcRenderer.on(signals.response(signals.APPLICATION.DELETE_ONE), (event, arg) => { 278 | this.getApplications(); 279 | this.getRules(); 280 | }); 281 | 282 | ipcRenderer.on(signals.response(signals.APPLICATION.READ), (event, arg) => { 283 | this.data.applications.splice(0, this.data.applications.length); // keep the reference to the array 284 | for (let application of arg) { 285 | this.data.applications.push(application); 286 | } 287 | this.data.settings.default_application = this.data.applications.find((application) => { 288 | return application.is_default === true; 289 | }); 290 | $rootScope.$apply(); 291 | }); 292 | 293 | ipcRenderer.on(signals.response(signals.APPLICATION.CLEAR), (event, arg) => { 294 | this.getApplications(); 295 | this.getRules(); 296 | }); 297 | 298 | // Applications: event emitters 299 | this.getApplications = function () { 300 | ipcRenderer.send(signals.request(signals.APPLICATION.READ), {}); 301 | }; 302 | 303 | this.deleteApplication = function (application) { 304 | let query = application && application._id ? {_id: application._id} : {_id: this.data.application._id}; 305 | ipcRenderer.send(signals.request(signals.APPLICATION.DELETE_ONE), {query: query}); 306 | }; 307 | 308 | this.createApplication = function () { 309 | ipcRenderer.send(signals.request(signals.APPLICATION.CREATE_ONE), {values: this.data.application}); 310 | }; 311 | 312 | this.updateApplication = function (application) { 313 | if (!application) { 314 | application = this.data.application 315 | } 316 | ipcRenderer.send( 317 | signals.request(signals.APPLICATION.UPDATE_ONE), 318 | {query: {_id: application._id}, values: application} 319 | ); 320 | }; 321 | 322 | this.setDefaultApplication = function () { 323 | ipcRenderer.send( 324 | signals.request(signals.APPLICATION.UPDATE_DEFAULT), {query: {_id: this.data.settings.default_application._id}} 325 | ); 326 | }; 327 | 328 | this.resetAll = function () { 329 | ipcRenderer.sendSync(signals.request(signals.CONFIG.RESET_ALL), {}); 330 | }; 331 | }); 332 | 333 | myApp.service('actions', function () { 334 | let _canSave = false; 335 | let _canDelete = false; 336 | return { 337 | getCanSave: function () { 338 | return _canSave; 339 | }, 340 | setCanSave: function (value) { 341 | _canSave = value; 342 | }, 343 | getCanDelete: function () { 344 | return _canDelete; 345 | }, 346 | setCanDelete: function (value) { 347 | _canDelete = value; 348 | } 349 | } 350 | }); 351 | 352 | myApp.filter('capitalize', function () { 353 | return function (input) { 354 | if (input) { 355 | return (input.charAt(0).toUpperCase() + input.slice(1)).split('_').join(' '); 356 | } 357 | }; 358 | }); 359 | 360 | myApp.filter('filterRules', function () { 361 | return function (input, filterCriteria) { 362 | if (!filterCriteria.text && !filterCriteria.filterByStatus) { 363 | return input; 364 | } 365 | let filterText = filterCriteria.text.toLowerCase(); 366 | let filterByStatus = filterCriteria.filterByStatus; 367 | return input.filter((i) => { 368 | return (i.name.toLowerCase().includes(filterText) && (filterByStatus ? i.is_active : true)) || 369 | i.conditions.some((c) => { 370 | return c.text.toLowerCase().includes(filterText); 371 | }) && (filterByStatus ? i.is_active : true); 372 | }) 373 | } 374 | }); 375 | 376 | myApp.filter('filterApps', function () { 377 | return function (input, filterCriteria) { 378 | if (!filterCriteria.text && !filterCriteria.filterByStatus) { 379 | return input; 380 | } 381 | let filterText = filterCriteria.text.toLowerCase(); 382 | let filterByStatus = filterCriteria.filterByStatus; 383 | return input.filter((i) => { 384 | return (i.name.toLowerCase().includes(filterText) || 385 | i.display_name.toLowerCase().includes(filterText) || 386 | i.executable.toLowerCase().includes(filterText) || 387 | i.identifier.toLowerCase().includes(filterText)) && 388 | (filterByStatus ? i.is_active : true); 389 | }) 390 | } 391 | }); 392 | 393 | myApp.constant('DEFAULT_CONDITION', { 394 | 'operand': 'host', 395 | 'operator': 'is', 396 | 'text': '', 397 | 'is_active': true 398 | }); 399 | 400 | myApp.constant('CONDITIONS_OPTIONS', evaluators.getConditionOperandsObject()); 401 | 402 | myApp.controller('MainCtrl', function ($scope, $rootScope, $q, $timeout, storage, actions, rulesTester) { 403 | // General error listener that exposes errors from node.js to AngularJS 404 | ipcRenderer.on(signals.response(signals.GENERAL.ERROR), (event, error) => { 405 | console.error(error); 406 | dialog.showErrorBox('Error', error); 407 | }); 408 | 409 | $scope.rules = []; 410 | $scope.icons = {}; 411 | $scope.applications = []; 412 | 413 | $scope.filterCriteria = {'text': '', 'filterByStatus': false}; 414 | 415 | $scope.clearFilterCriteria = function () { 416 | $scope.filterCriteria.text = ''; 417 | $scope.filterByStatus = false; 418 | }; 419 | 420 | $scope.toggleFilterCriteria = function () { 421 | $scope.filterCriteria.filterByStatus = !$scope.filterCriteria.filterByStatus; 422 | }; 423 | 424 | // services 425 | $scope.storage = storage; 426 | $scope.actions = actions; 427 | 428 | $scope.actions.setCanSave(false); 429 | $scope.actions.setCanDelete(false); 430 | 431 | $scope.rulesTester = rulesTester; 432 | 433 | $scope._toggleAddAppDialog = function () { 434 | if (storage.data.application._id) { 435 | return; 436 | } 437 | let opts = { 438 | title: 'Select application', 439 | defaultPath: '/Applications', 440 | filters: [ 441 | {name: 'Applications', extensions: ['app']} 442 | ], 443 | properties: ['openFile'] 444 | }; 445 | let selectedApp = dialog.showOpenDialog(remote.getCurrentWindow(), opts); 446 | if (angular.isDefined(selectedApp) && selectedApp !== null) { 447 | if (angular.isArray(selectedApp)) { 448 | selectedApp = selectedApp[0] 449 | } 450 | let appInfo = utils.getAppInfo(selectedApp, false); 451 | storage.data.application.name = appInfo.name; 452 | storage.data.application.path = appInfo.path; 453 | storage.data.application.icns = appInfo.icns; 454 | storage.data.application.display_name = appInfo.display_name; 455 | storage.data.application.executable = appInfo.executable; 456 | storage.data.application.identifier = appInfo.identifier; 457 | storage.data.application.is_active = true; 458 | storage.data.application.is_default = false; 459 | } 460 | }; 461 | 462 | $scope.toggleAddAppDialog = function () { 463 | $timeout(function () { 464 | $scope._toggleAddAppDialog(); 465 | $scope.$apply(); 466 | }, 500); 467 | }; 468 | 469 | $scope.loadApplication = function (data) { 470 | storage.loadApplication(data || {}); 471 | actions.setCanSave(false); 472 | }; 473 | 474 | $scope.loadRule = function (data) { 475 | storage.loadRule(data || {}); 476 | rulesTester.reset(); 477 | actions.setCanSave(false); 478 | }; 479 | 480 | $scope.reloadAppsView = function () { 481 | $scope.clearFilterCriteria(); 482 | // Sort by date so that the newest item goes first 483 | storage.data.applications.sort(function (a, b) { 484 | return new Date(b.created_on).getTime() - new Date(a.created_on).getTime(); 485 | }); 486 | 487 | $scope.loadApplication({}); 488 | if (storage.data.applications.length) { 489 | $scope.loadApplication(storage.data.applications[0]) 490 | } 491 | $scope.ctx.switchToAppsView(); 492 | }; 493 | 494 | $scope.reloadRulesView = function () { 495 | $scope.clearFilterCriteria(); 496 | // Sort by date so that the newest item goes first 497 | storage.data.rules.sort(function (a, b) { 498 | return new Date(b.created_on).getTime() - new Date(a.created_on).getTime(); 499 | }); 500 | 501 | $scope.loadRule({}); 502 | if (storage.data.rules.length) { 503 | $scope.loadRule(storage.data.rules[0]) 504 | } 505 | rulesTester.reset(); 506 | $scope.ctx.switchToRulesView(); 507 | }; 508 | 509 | $scope.reloadSettingsView = function () { 510 | storage.getIsUseDefault(); 511 | storage.getAppStatus(); 512 | storage.getApplications(); 513 | $scope.clearFilterCriteria(); 514 | $scope.ctx.switchToSettingsView(); 515 | }; 516 | 517 | // DB operations 518 | $scope.saveRule = function () { 519 | if (storage.data.rule && storage.data.rule._id) { 520 | storage.updateRule(); 521 | } else { 522 | storage.createRule(); 523 | } 524 | }; 525 | 526 | $scope.saveApplication = function () { 527 | if (storage.data.application && storage.data.application._id) { 528 | storage.updateApplication(); 529 | } else { 530 | storage.createApplication(); 531 | storage.getIcons(); 532 | } 533 | }; 534 | 535 | $scope.load = function () { 536 | switch (true) { 537 | case $scope.ctx.isRulesView(): 538 | $scope.loadRule(); 539 | break; 540 | case $scope.ctx.isAppsView(): 541 | $scope.loadApplication(); 542 | $scope.toggleAddAppDialog(); 543 | break; 544 | default: 545 | $scope.reloadRulesView(); 546 | break; 547 | } 548 | }; 549 | 550 | $scope.save = function () { 551 | switch (true) { 552 | case $scope.ctx.isRulesView(): 553 | $scope.saveRule(); 554 | $scope.reloadRulesView(); 555 | break; 556 | case $scope.ctx.isAppsView(): 557 | $scope.saveApplication(); 558 | $scope.reloadAppsView(); 559 | break; 560 | case $scope.ctx.isSettingsView(): 561 | storage.saveAppStatus(); 562 | storage.saveIsUseDefault(); 563 | storage.setDefaultApplication(); 564 | break; 565 | default: 566 | $scope.reloadRulesView(); 567 | break; 568 | } 569 | }; 570 | 571 | $scope.cancel = function () { 572 | switch (true) { 573 | case $scope.ctx.isAppsView(): 574 | $scope.reloadAppsView(); 575 | break; 576 | case $scope.ctx.isRulesView(): 577 | $scope.reloadRulesView(); 578 | break; 579 | case $scope.ctx.isSettingsView(): 580 | $scope.reloadSettingsView(); 581 | break; 582 | default: 583 | $scope.reloadRulesView(); 584 | break; 585 | } 586 | }; 587 | 588 | $scope.delete = function (data) { 589 | switch (true) { 590 | case $scope.ctx.isRulesView(): 591 | storage.deleteRule(data); 592 | $scope.reloadRulesView(); 593 | break; 594 | case $scope.ctx.isAppsView(): 595 | storage.deleteApplication(data); 596 | $scope.reloadAppsView(); 597 | break; 598 | case $scope.ctx.isSettingsView(): 599 | storage.resetAll(); 600 | $scope.reloadSettingsView(); 601 | break; 602 | } 603 | }; 604 | 605 | $scope.showConfirmDelete = function () { 606 | // Currently, spectron cannot interact with dialogs so I just skip them during testing. 607 | // https://github.com/electron/spectron/issues/94 608 | if (process.env.RUNNING_IN_SPECTRON) { 609 | $scope.delete(); 610 | return; 611 | } 612 | let _textContent = 'The action cannot be undone. Do you want to continue?'; 613 | let _textConfirm = 'Delete'; 614 | let _textTitle = 'Confirm delete'; 615 | let _textDetail = null; 616 | let canDelete = true; 617 | switch (true) { 618 | case $scope.ctx.isRulesView(): 619 | _textDetail = 'Note: It is possible to deactivate a rule, not delete.'; 620 | break; 621 | case $scope.ctx.isAppsView(): 622 | _textDetail = 'Note: All the rules that use this application will be deleted.'; 623 | canDelete = storage.data.settings.default_application._id !== storage.data.application._id; 624 | if (!canDelete) { 625 | _textDetail = 'Note: You can change favorite browser in "Options" tab.'; 626 | _textContent = `Cannot delete ${storage.data.application.display_name} as it is used as a favorite browser.` 627 | } 628 | break; 629 | case $scope.ctx.isSettingsView(): 630 | _textConfirm = 'Reset'; 631 | _textTitle = 'Confirm reset'; 632 | _textDetail = 'Note: This action will clear the application settings including configured applications and rules. ' + 633 | 'Please make a backup of "~/Library/Application Support/BrowserDispatcher/databases" before you proceed.'; 634 | break 635 | } 636 | let opts = { 637 | type: 'warning', 638 | title: _textTitle, 639 | message: _textContent, 640 | detail: _textDetail, 641 | buttons: canDelete ? [_textConfirm, 'Cancel'] : ['Cancel'] 642 | }; 643 | 644 | dialog.showMessageBox(remote.getCurrentWindow(), opts, (idx) => { 645 | if (opts.buttons.length === 2 && idx === 0) { 646 | $scope.delete(); 647 | } 648 | }); 649 | }; 650 | 651 | $scope.showHelp = function () { 652 | ipcRenderer.send(signals.request(signals.GENERAL.SHOW_MAN_PAGE), {}) 653 | }; 654 | 655 | $scope.init = function () { 656 | $q.all([ 657 | storage.getAppStatus(), 658 | storage.getIsUseDefault(), 659 | storage.getRules(), 660 | storage.getIcons(), 661 | storage.getApplications() 662 | ]).then( 663 | () => { 664 | $scope.rules = storage.data.rules; 665 | $scope.icons = storage.data.icons; 666 | $scope.applications = storage.data.applications; 667 | 668 | $scope.reloadRulesView(); 669 | }, 670 | error => { 671 | console.error(error); 672 | dialog.showErrorBox('Error', error); 673 | } 674 | ); 675 | }; 676 | 677 | $scope.$watch('rules', (newValue, oldValue) => { 678 | if (newValue.length && $scope.ctx.isRulesView()) { 679 | $scope.reloadRulesView(); 680 | } 681 | }, true); 682 | 683 | $scope.$watch('applications', (newValue, oldValue) => { 684 | if (newValue.length && $scope.ctx.isAppsView()) { 685 | $scope.reloadAppsView(); 686 | } 687 | }, true); 688 | 689 | $scope.init(); 690 | }); 691 | 692 | myApp.directive('removeCondition', function () { 693 | return { 694 | scope: { 695 | index: '=?' 696 | }, 697 | link: function ($scope, $element) { 698 | $element.bind('click', function () { 699 | $scope.$emit('condition-remove', $scope.index); 700 | }); 701 | } 702 | }; 703 | }); 704 | 705 | myApp.directive('toggleCondition', function () { 706 | return { 707 | scope: { 708 | index: '=?' 709 | }, 710 | link: function ($scope, $element) { 711 | $element.bind('click', function () { 712 | $scope.$emit('condition-toggle', $scope.index); 713 | }); 714 | } 715 | }; 716 | }); 717 | 718 | myApp.directive('addCondition', function () { 719 | return { 720 | scope: { 721 | index: '=?' 722 | }, 723 | link: function ($scope, $element) { 724 | $element.bind('click', function () { 725 | $scope.$emit('condition-add', $scope.index); 726 | }); 727 | } 728 | }; 729 | }); 730 | 731 | myApp.directive('editCondition', function () { 732 | return { 733 | restrict: 'E', 734 | scope: { 735 | condition: '=?condition', 736 | index: '=?index' 737 | }, 738 | replace: true, 739 | transclude: true, 740 | templateUrl: './src/app/templates/edit_condition.html', 741 | 742 | controller: function ($scope, CONDITIONS_OPTIONS) { 743 | let conditionOptions = angular.copy(CONDITIONS_OPTIONS); 744 | $scope.operands = Object.keys(conditionOptions); 745 | $scope.operators = conditionOptions[$scope.condition.operand]; 746 | 747 | $scope.$watch('condition.operand', function (newValue, oldValue) { 748 | if (newValue === oldValue) { 749 | return; 750 | } 751 | let operators = conditionOptions[$scope.condition.operand]; 752 | if (!angular.equals($scope.operators, operators)) { 753 | $scope.operators = conditionOptions[$scope.condition.operand]; 754 | $scope.condition.operator = $scope.operators[0]; 755 | } 756 | }); 757 | } 758 | } 759 | }); 760 | 761 | myApp.controller('SettingsCtrl', function ($scope, storage, actions) { 762 | // a tiny proxy Controller for storage objects 763 | $scope.settings = storage.data.settings; 764 | $scope.applications = storage.data.applications; 765 | actions.setCanSave(true); 766 | actions.setCanDelete(true); 767 | }); 768 | 769 | myApp.controller('RuleCtrl', function ($scope, DEFAULT_CONDITION, storage, actions) { 770 | $scope.rule = storage.data.rule; 771 | $scope.applications = storage.data.applications; 772 | 773 | actions.setCanSave(false); 774 | actions.setCanDelete(false); 775 | 776 | $scope.toggleActive = function () { 777 | $scope.editRule.$dirty = true; 778 | $scope.rule.is_active = !$scope.rule.is_active; 779 | }; 780 | 781 | $scope.isActive = function () { 782 | return (angular.isDefined($scope.rule) && $scope.rule && $scope.rule.is_active); 783 | }; 784 | 785 | $scope.hasConditions = function () { 786 | return $scope.rule.conditions.length > 0; 787 | }; 788 | 789 | $scope.$on('condition-add', function (e, index) { 790 | $scope.editRule.$dirty = true; 791 | $scope.rule.conditions.splice(Number.parseInt(index) + 1, 0, angular.copy(DEFAULT_CONDITION)); 792 | $scope.$apply(); 793 | }); 794 | 795 | $scope.$on('condition-remove', function (e, index) { 796 | if ($scope.rule.conditions.length > 1) { 797 | $scope.editRule.$dirty = true; 798 | $scope.rule.conditions.splice(Number.parseInt(index), 1); 799 | $scope.$apply(); 800 | } 801 | }); 802 | 803 | $scope.$on('condition-toggle', function (e, index) { 804 | if ($scope.rule.conditions.length > 1) { 805 | $scope.editRule.$dirty = true; 806 | $scope.rule.conditions[index].is_active = !$scope.rule.conditions[index].is_active; 807 | $scope.$apply(); 808 | } 809 | }); 810 | 811 | $scope.$watch('rule', (newValue, oldValue) => { 812 | if (newValue._id !== oldValue._id) { 813 | $scope.editRule.$dirty = false; 814 | } 815 | actions.setCanDelete(angular.isDefined($scope.rule._id)); 816 | if (newValue !== oldValue && $scope.editRule.$dirty) { 817 | actions.setCanSave([ 818 | newValue.name, 819 | newValue.conditions && newValue.conditions.every(function (c) { return c.text; }) 820 | ].every(function (attr) { return attr; })); 821 | } 822 | }, true); 823 | }); 824 | 825 | myApp.controller('ApplicationCtrl', function ($scope, $timeout, storage, actions) { 826 | $scope.application = storage.data.application; 827 | 828 | actions.setCanSave(false); 829 | actions.setCanDelete(false); 830 | 831 | $scope.toggleActive = function () { 832 | $scope.editApp.$dirty = true; 833 | $scope.application.is_active = !$scope.application.is_active; 834 | }; 835 | 836 | $scope.isActive = function () { 837 | return (angular.isDefined($scope.application) && $scope.application.is_active); 838 | }; 839 | 840 | $scope.$watch('application', (newValue, oldValue) => { 841 | if (newValue._id !== oldValue._id) { 842 | $scope.editApp.$dirty = false; 843 | } 844 | actions.setCanDelete(angular.isDefined($scope.application._id)); 845 | if (newValue !== oldValue && $scope.editApp.$dirty) { 846 | actions.setCanSave([ 847 | newValue.name, 848 | newValue.executable, 849 | newValue.identifier, 850 | newValue.display_name 851 | ].every(function (attr) { return attr; })); 852 | } 853 | }, true) 854 | }); 855 | -------------------------------------------------------------------------------- /src/app/app2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const electron = require('electron'); 4 | const {remote, ipcRenderer} = require('electron'); 5 | const {dialog} = require('electron').remote; 6 | const signals = require('./src/app/js/signals'); 7 | 8 | const myApp2 = angular.module('myApp2', ['ngSanitize', 'ngMaterial', 'ngMessages']); 9 | 10 | myApp2.controller('MainCtrl2', function ($scope) { 11 | // General error listener that exposes errors from node.js to AngularJS 12 | ipcRenderer.on(signals.response(signals.GENERAL.ERROR), (event, error) => { 13 | console.error(error); 14 | dialog.showErrorBox('Error', error) 15 | }); 16 | 17 | $scope.applications = []; 18 | $scope.icons = []; 19 | $scope.urls = []; 20 | 21 | $scope.win = null; 22 | 23 | // Load URL that should be opened 24 | ipcRenderer.on(signals.response(signals.GENERAL.LOAD_URL), (event, arg) => { 25 | $scope.urls = [arg]; 26 | $scope.showWin(); 27 | }); 28 | 29 | // Icons: listeners 30 | ipcRenderer.on(signals.response(signals.ICON.READ), (event, arg) => { 31 | let sizeToName = { 32 | 'icon_16x16.png': 'mini', 33 | 'icon_32x32.png': 'small', 34 | 'icon_32x32@2x.png': 'medium', 35 | 'icon_128x128.png': 'large' 36 | }; 37 | arg.forEach(result => { 38 | if (!$scope.icons[result.application]) { 39 | $scope.icons[result.application] = {}; 40 | } 41 | let alias = sizeToName[result.name]; 42 | if (alias) { 43 | $scope.icons[result.application][alias] = result.content; 44 | } 45 | }); 46 | $scope.$apply(); 47 | }); 48 | 49 | // Icons: event emitters 50 | $scope.getIcons = function () { 51 | ipcRenderer.send(signals.request(signals.ICON.READ), {query: {}, options: {populate: false}}); 52 | }; 53 | 54 | ipcRenderer.on(signals.response(signals.APPLICATION.READ), (event, arg) => { 55 | $scope.applications.splice(0, $scope.applications.length); // keep the reference to the array 56 | for (let application of arg) { 57 | $scope.applications.push(application); 58 | } 59 | $scope.$apply(); 60 | }); 61 | 62 | $scope.getApplications = function () { 63 | ipcRenderer.send(signals.request(signals.APPLICATION.READ), {}); 64 | }; 65 | 66 | $scope.openApplication = function (application) { 67 | ipcRenderer.send( 68 | signals.request(signals.GENERAL.OPEN_URL), 69 | {application: application, url: $scope.urls[0]} 70 | ); 71 | }; 72 | 73 | $scope.getApplications(); 74 | $scope.getIcons(); 75 | 76 | $scope.hideWin = function () { 77 | $scope.win = $scope.win || remote.getCurrentWindow(); 78 | if ($scope.win && $scope.win.isVisible()) { 79 | $scope.win.hide(); 80 | } 81 | }; 82 | 83 | $scope.getWin = function () { 84 | $scope.win = remote.getCurrentWindow(); 85 | let {width, height} = electron.screen.getPrimaryDisplay().workArea; 86 | let {x, y} = electron.screen.getCursorScreenPoint(); 87 | // width +50 for left & right padding 88 | let w = Math.min($scope.applications.length * 42 + 50, 800); 89 | let h = 100; 90 | let x2 = x - Math.round(w / 2); 91 | let y2 = y - h; 92 | 93 | // make sure that the pop-up window is within the screen area 94 | x2 = Math.max(x2, 0); 95 | x2 = Math.min((x2 + w), width) - w; 96 | 97 | y2 = Math.max(y2, 0); 98 | y2 = Math.min((y2 + h), height) - h; 99 | 100 | $scope.win.setSize(w, h); 101 | $scope.win.setPosition(x2, y2); 102 | }; 103 | 104 | $scope.showWin = function () { 105 | if (!$scope.win) { 106 | $scope.getWin(); 107 | } 108 | if (!$scope.win.isVisible()) { 109 | $scope.win.show(); 110 | $scope.win.focus(); 111 | } 112 | }; 113 | 114 | $scope.$watch('applications', () => { 115 | $scope.getWin(); 116 | }, true); 117 | }); 118 | -------------------------------------------------------------------------------- /src/app/css/app.css: -------------------------------------------------------------------------------- 1 | :focus { 2 | outline: none; 3 | } 4 | 5 | * { 6 | cursor: default; 7 | -webkit-user-drag: none; 8 | -webkit-user-select: none; 9 | -webkit-box-sizing: border-box; 10 | box-sizing: border-box; 11 | } 12 | 13 | input[type="file"]{ 14 | font-size: 0; 15 | } 16 | 17 | input[type="file"]::-webkit-file-upload-button{ 18 | font-size: 14px; /*normal size*/ 19 | } 20 | 21 | .bd-form-control { 22 | margin-left: 4px; 23 | display: inline; 24 | width: 90%; 25 | min-height: 12px; 26 | padding: 5px 10px; 27 | font-size: inherit; 28 | line-height: normal; 29 | border: 1px solid #ddd; 30 | border-radius: 4px; 31 | outline: none; 32 | } 33 | 34 | .bd-padded { 35 | margin-top: 10px; 36 | margin-bottom: 10px; 37 | } 38 | 39 | .bd-select-input { 40 | display: inline; 41 | width: 25%; 42 | } 43 | 44 | .bd-text-input { 45 | padding-left: 4px; 46 | padding-right: 4px; 47 | display: inline; 48 | line-height: 130%; 49 | background-color: #fff; 50 | border: 1px solid #ddd; 51 | border-radius: 3px; 52 | outline: none; 53 | } 54 | 55 | .bd-text-input:focus { 56 | outline: -webkit-focus-ring-color auto 3px; 57 | } 58 | 59 | .bd-toolbar-actions { 60 | -webkit-app-region: none; 61 | } 62 | 63 | .box { 64 | width: 100%; 65 | } 66 | 67 | .bd-button, .bd-button-group { 68 | vertical-align: inherit; 69 | } 70 | 71 | .bd-button-mini { 72 | padding: 1px 6px; 73 | } 74 | 75 | .bd-pane-sm { 76 | max-width: 30%; 77 | min-width: 200px 78 | } 79 | 80 | .bd-center { 81 | display: flex; 82 | justify-content: center; 83 | align-items: center; 84 | } 85 | 86 | .bd-center-end { 87 | display:flex; 88 | justify-content:flex-end; 89 | align-items:center; 90 | } 91 | 92 | .bd-center-start { 93 | display:flex; 94 | justify-content:flex-start; 95 | align-items:center; 96 | } 97 | 98 | 99 | span.bd-shift-span { 100 | margin-right: 2px; 101 | } 102 | 103 | button.bd-asymmetric { 104 | border-bottom-left-radius: 0; 105 | border-top-left-radius: 0; 106 | margin-left: -2px; 107 | padding-right: 12px 108 | } 109 | 110 | .bd-button-icon, .bd-button-icon:active { 111 | background: transparent none; 112 | border: none; 113 | box-shadow: none; 114 | } 115 | 116 | #toolbarTop { 117 | box-shadow: none; 118 | } -------------------------------------------------------------------------------- /src/app/css/app2.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-family: 'Roboto', sans-serif; 3 | font-size: 14px; 4 | height: 100%; 5 | margin: 0; 6 | padding: 0; 7 | overflow-x: hidden; 8 | background-color: rgba(115,116,117,0.8); 9 | } 10 | 11 | md-toolbar { 12 | margin-top: 10px; 13 | background-color: transparent !important; 14 | } 15 | 16 | .md-toolbar-tools { 17 | padding-left: 35px; 18 | padding-right: 35px; 19 | } 20 | 21 | .md-button.md-icon-button { 22 | margin: 0; 23 | height: 60px; 24 | min-width: 0; 25 | line-height: 24px; 26 | padding-left: 0; 27 | padding-right: 0; 28 | width: 60px; 29 | border-radius: 50%; 30 | cursor: default; 31 | } 32 | 33 | .md-chips { 34 | font-size: 12px; 35 | padding-bottom: 0; 36 | box-shadow: none !important; 37 | } 38 | 39 | .md-chips md-chip { 40 | height: 20px; 41 | line-height: 18px; 42 | background-color: rgba(115,116,117,0.9); 43 | color: rgb(255,255,255); 44 | box-shadow: none !important; 45 | } 46 | 47 | md-icon { 48 | margin-left: 15px; 49 | margin-right: -20px; 50 | margin-top: 0; 51 | } 52 | -------------------------------------------------------------------------------- /src/app/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Material Icons'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local('Material Icons'), local('MaterialIcons-Regular'), url('fonts/MaterialIcons.woff') format('woff'); 6 | } 7 | 8 | .material-icons { 9 | font-family: 'Material Icons'; 10 | font-weight: normal; 11 | font-style: normal; 12 | font-size: 24px; 13 | line-height: 1; 14 | letter-spacing: normal; 15 | text-transform: none; 16 | display: inline-block; 17 | white-space: nowrap; 18 | word-wrap: normal; 19 | direction: ltr; 20 | text-rendering: optimizeLegibility; 21 | -webkit-font-smoothing: antialiased; 22 | } 23 | 24 | 25 | @font-face { 26 | font-family: 'Roboto'; 27 | font-style: normal; 28 | font-weight: 300; 29 | src: local('Roboto Light'), local('Roboto-Light'), url('fonts/Roboto-Light.woff') format('woff'); 30 | } 31 | @font-face { 32 | font-family: 'Roboto'; 33 | font-style: normal; 34 | font-weight: 400; 35 | src: local('Roboto'), local('Roboto-Regular'), url('fonts/Roboto-Regular.woff') format('woff'); 36 | } 37 | @font-face { 38 | font-family: 'Roboto'; 39 | font-style: normal; 40 | font-weight: 500; 41 | src: local('Roboto Medium'), local('Roboto-Medium'), url('fonts/Roboto-Medium.woff') format('woff'); 42 | } 43 | @font-face { 44 | font-family: 'Roboto'; 45 | font-style: normal; 46 | font-weight: 700; 47 | src: local('Roboto Bold'), local('Roboto-Bold'), url('fonts/Roboto-Bold.woff') format('woff'); 48 | } 49 | @font-face { 50 | font-family: 'Roboto'; 51 | font-style: italic; 52 | font-weight: 400; 53 | src: local('Roboto Italic'), local('Roboto-Italic'), url('fonts/Roboto-Italic.woff') format('woff'); 54 | } 55 | 56 | .roboto { 57 | font-family: 'Roboto', sans-serif; 58 | font-weight: normal; 59 | font-style: normal; 60 | font-size: 24px; 61 | } -------------------------------------------------------------------------------- /src/app/css/fonts/MaterialIcons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/src/app/css/fonts/MaterialIcons.woff -------------------------------------------------------------------------------- /src/app/css/fonts/Roboto-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/src/app/css/fonts/Roboto-Bold.woff -------------------------------------------------------------------------------- /src/app/css/fonts/Roboto-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/src/app/css/fonts/Roboto-Italic.woff -------------------------------------------------------------------------------- /src/app/css/fonts/Roboto-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/src/app/css/fonts/Roboto-Light.woff -------------------------------------------------------------------------------- /src/app/css/fonts/Roboto-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/src/app/css/fonts/Roboto-Medium.woff -------------------------------------------------------------------------------- /src/app/css/fonts/Roboto-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/src/app/css/fonts/Roboto-Regular.woff -------------------------------------------------------------------------------- /src/app/css/icons.css: -------------------------------------------------------------------------------- 1 | /* Rules for sizing the icon. */ 2 | .material-icons.md-12 { 3 | font-size: 12px; 4 | height: 12px; 5 | width: 12px; 6 | min-height: 12px; 7 | min-width: 12px; 8 | } 9 | 10 | .material-icons.md-14 { 11 | font-size: 14px; 12 | height: 14px; 13 | width: 14px; 14 | min-height: 14px; 15 | min-width: 14px; 16 | } 17 | 18 | .material-icons.md-16 { 19 | font-size: 16px; 20 | height: 16px; 21 | width: 16px; 22 | min-height: 16px; 23 | min-width: 16px; 24 | } 25 | 26 | .material-icons.md-18 { 27 | font-size: 18px; 28 | height: 18px; 29 | width: 18px; 30 | min-height: 18px; 31 | min-width: 18px; 32 | 33 | } 34 | 35 | .material-icons.md-24 { 36 | font-size: 24px; 37 | height: 24px; 38 | width: 24px; 39 | min-height: 24px; 40 | min-width: 24px; 41 | } 42 | 43 | .material-icons.md-36 { 44 | font-size: 36px; 45 | height: 36px; 46 | width: 36px; 47 | min-height: 36px; 48 | min-width: 36px; 49 | } 50 | 51 | .material-icons.md-48 { 52 | font-size: 48px; 53 | height: 48px; 54 | width: 48px; 55 | min-height: 48px; 56 | min-width: 48px; 57 | } 58 | 59 | /* Rules for using icons as black on a light background. */ 60 | .material-icons.md-danger { color: rgba(255, 17, 14, 0.84); } 61 | .material-icons.md-danger.md-inactive { color: rgba(255, 17, 14, 0.54); } 62 | 63 | md-icon.md-danger { color: rgba(255, 17, 14, 0.84); } 64 | md-icon.md-danger.md-inactive { color: rgba(255, 17, 14, 0.54); } 65 | 66 | .material-icons.md-dark { color: rgba(0, 0, 0, 0.54); } 67 | .material-icons.md-dark.md-inactive { color: rgba(0, 0, 0, 0.26); } 68 | 69 | /* Rules for using icons as white on a dark background. */ 70 | .material-icons.md-light { color: rgba(255, 255, 255, 1); } 71 | .material-icons.md-light.md-inactive { color: rgba(255, 255, 255, 0.3); } 72 | 73 | /* Rules for using icons as black on a light background. */ 74 | md-icon.md-dark { color: rgba(0, 0, 0, 0.54); } 75 | md-icon.md-dark.md-inactive { color: rgba(0, 0, 0, 0.26); } 76 | 77 | /* Rules for using icons as white on a dark background. */ 78 | md-icon.md-light { color: rgba(255, 255, 255, 1); } 79 | md-icon.md-light.md-inactive { color: rgba(255, 255, 255, 0.3); } 80 | -------------------------------------------------------------------------------- /src/app/images/tray_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/src/app/images/tray_icon.png -------------------------------------------------------------------------------- /src/app/js/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const COMMON = { 4 | APP_NAME: 'BrowserDispatcher' 5 | }; 6 | 7 | const CONDITION_OPERATOR = { 8 | IS: 'is', 9 | IS_NOT: 'is_not', 10 | CONTAINS: 'contains', 11 | NOT_CONTAINS: 'not_contains', 12 | STARTS_WITH: 'starts_with', 13 | NOT_STARTS_WITH: 'not_starts_with', 14 | ENDS_WITH: 'ends_with', 15 | NOT_ENDS_WITH: 'not_ends_with', 16 | REGEX: 'regular_expression' 17 | }; 18 | 19 | const CONDITION_OPERAND = { 20 | URL: 'url', 21 | APP: 'app', 22 | HOST: 'host', 23 | SCHEME: 'scheme', 24 | PATH: 'path', 25 | PORT: 'port', 26 | EXTENSION: 'extension' 27 | }; 28 | 29 | const RULE_OPERATOR = { 30 | ALL: 'all', 31 | ANY: 'any' 32 | }; 33 | 34 | const STATUS = { 35 | IS_APP_ENABLED: 'is_app_enabled', 36 | IS_DEV_MODE: 'is_dev_mode', 37 | IS_USE_DEFAULT: 'is_use_default' 38 | }; 39 | 40 | module.exports = { 41 | 'COMMON': COMMON, 42 | 'RULE_OPERATOR': RULE_OPERATOR, 43 | 'CONDITION_OPERATOR': CONDITION_OPERATOR, 44 | 'CONDITION_OPERAND': CONDITION_OPERAND, 45 | 'STATUS': STATUS 46 | }; 47 | -------------------------------------------------------------------------------- /src/app/js/docs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const Promise = require('bluebird'); 5 | 6 | const {Document, EmbeddedDocument, connect} = require('camo'); 7 | 8 | const utils = require('./utils'); 9 | const CONST = require('./constants'); 10 | const {getLogger} = require('./logger'); 11 | 12 | class Condition extends EmbeddedDocument { 13 | constructor () { 14 | super(); 15 | 16 | this.text = { 17 | type: String, 18 | required: true 19 | }; 20 | this.is_active = { 21 | type: Boolean, 22 | default: true 23 | }; 24 | this.operand = { 25 | type: String, 26 | choices: utils.objectValues(CONST.CONDITION_OPERAND), 27 | required: true 28 | }; 29 | this.operator = { 30 | type: String, 31 | choices: utils.objectValues(CONST.CONDITION_OPERATOR), 32 | required: true 33 | }; 34 | this.created_on = { 35 | type: Date, 36 | default: Date.now 37 | }; 38 | }; 39 | 40 | preValidate () { 41 | this.created_on = new Date(); 42 | return this; 43 | }; 44 | 45 | static collectionName () { 46 | return 'conditions'; 47 | }; 48 | } 49 | 50 | class Icon extends Document { 51 | constructor () { 52 | super(); 53 | 54 | this.name = { 55 | required: true, 56 | type: String 57 | }; 58 | this.content = { 59 | required: true, 60 | type: String 61 | }; 62 | this.application = { 63 | required: true, 64 | type: Application 65 | }; 66 | this.created_on = { 67 | type: Date, 68 | default: Date.now 69 | }; 70 | } 71 | 72 | preValidate () { 73 | this.created_on = new Date(); 74 | if (Buffer.isBuffer(this.content)) { 75 | this.content = this.content.toString('base64'); 76 | } 77 | return this; 78 | }; 79 | 80 | static collectionName () { 81 | return 'icons'; 82 | }; 83 | } 84 | 85 | class Application extends Document { 86 | constructor () { 87 | super(); 88 | 89 | this.name = { 90 | unique: true, 91 | required: true, 92 | type: String 93 | }; 94 | this.path = { 95 | unique: true, 96 | required: true, 97 | type: String 98 | }; 99 | this.icns = { 100 | unique: true, 101 | required: true, 102 | type: String 103 | }; 104 | this.display_name = { 105 | required: true, 106 | type: String 107 | }; 108 | this.executable = { 109 | required: true, 110 | type: String 111 | }; 112 | this.identifier = { 113 | unique: true, 114 | required: true, 115 | type: String 116 | }; 117 | this.is_default = { 118 | type: Boolean, 119 | default: false 120 | }; 121 | this.is_active = { 122 | type: Boolean, 123 | default: true 124 | }; 125 | this.created_on = { 126 | type: Date, 127 | default: Date.now 128 | }; 129 | }; 130 | 131 | preValidate () { 132 | this.created_on = new Date(); 133 | return this; 134 | } 135 | 136 | static collectionName () { 137 | return 'applications'; 138 | }; 139 | } 140 | 141 | class Rule extends Document { 142 | constructor () { 143 | super(); 144 | 145 | this.name = { 146 | unique: true, 147 | required: true, 148 | type: String 149 | }; 150 | this.is_active = { 151 | type: Boolean, 152 | default: true 153 | }; 154 | this.operator = { 155 | type: String, 156 | choices: utils.objectValues(CONST.RULE_OPERATOR), 157 | required: true 158 | }; 159 | this.conditions = [Condition]; 160 | this.application = { 161 | required: true, 162 | type: Application 163 | }; 164 | // "-n" option 165 | // Open a new instance of the application(s) even if one is already running. 166 | this.open_new_instance = { 167 | type: Boolean, 168 | default: false 169 | }; 170 | // "-g" option 171 | // Do not bring the application to the foreground. 172 | this.open_not_foreground = { 173 | type: Boolean, 174 | default: false 175 | }; 176 | // "-F" option. 177 | // Opens the application "fresh," that is, without restoring windows. 178 | // Saved persistent state is lost, except for Untitled documents. 179 | this.open_fresh = { 180 | type: Boolean, 181 | default: false 182 | }; 183 | // "−b" option 184 | // Use bundle identifier of the application to use when opening the file/uri 185 | this.use_app_executable = { 186 | type: Boolean, 187 | default: false 188 | }; 189 | // "--args" option. 190 | // All remaining arguments are passed to the opened application in the argv parameter to main(). 191 | // These arguments are not opened or interpreted by the open tool. 192 | this.open_args = { 193 | type: String, 194 | required: false 195 | }; 196 | this.created_on = { 197 | type: Date, 198 | default: Date.now 199 | }; 200 | } 201 | 202 | preValidate () { 203 | this.created_on = new Date(); 204 | if (typeof this.application._id !== 'undefined') { 205 | this.application = this.application._id; 206 | } 207 | return this; 208 | } 209 | 210 | static collectionName () { 211 | return 'rules'; 212 | }; 213 | } 214 | 215 | class Preferences extends Document { 216 | constructor () { 217 | super(); 218 | 219 | this.name = { 220 | unique: true, 221 | required: true, 222 | type: String 223 | }; 224 | this.status = { 225 | default: true, 226 | type: Boolean 227 | }; 228 | this.created_on = { 229 | type: Date, 230 | default: Date.now 231 | }; 232 | }; 233 | 234 | static init (logger) { 235 | let initData = {name: CONST.STATUS.IS_APP_ENABLED}; 236 | return this.findOne(initData).then( 237 | foundData => { 238 | if (foundData) { 239 | logger.info(`Skipping initialization of "${this.name}"`); 240 | return foundData; 241 | } else { 242 | let appStatus = this.create(initData); 243 | let devStatus = this.create({name: CONST.STATUS.IS_DEV_MODE, status: false}); 244 | let useDefault = this.create({name: CONST.STATUS.IS_USE_DEFAULT, status: false}); 245 | Promise.all([appStatus.save(), devStatus.save(), useDefault.save()]).then( 246 | result => { 247 | logger.info(`Successfully initialized "${this.name}"`); 248 | return result; 249 | }, 250 | error => { 251 | logger.error(`Failed to initialize "${this.name}". Error: ${error}`); 252 | return Promise.reject(error); 253 | } 254 | ); 255 | } 256 | }, 257 | error => { 258 | logger.error(`Failed to initialize "${this.name}". Error: ${error}`); 259 | return Promise.reject(error); 260 | } 261 | ); 262 | }; 263 | 264 | static isAppEnabled () { 265 | return this.findOne({name: CONST.STATUS.IS_APP_ENABLED}); 266 | }; 267 | 268 | static enableApp () { 269 | return this.findOneAndUpdate({name: CONST.STATUS.IS_APP_ENABLED}, {status: true}, {}); 270 | }; 271 | 272 | static disableApp () { 273 | return this.findOneAndUpdate({name: CONST.STATUS.IS_APP_ENABLED}, {status: false}, {}); 274 | }; 275 | 276 | static toggleAppStatus () { 277 | return this.isAppEnabled().then( 278 | result => { 279 | if (result.status) { 280 | return this.disableApp(); 281 | } else { 282 | return this.enableApp(); 283 | } 284 | }, 285 | error => { 286 | return Promise.reject(error); 287 | } 288 | ); 289 | }; 290 | 291 | preValidate () { 292 | this.created_on = new Date(); 293 | return this; 294 | }; 295 | 296 | static collectionName () { 297 | return 'settings'; 298 | } 299 | } 300 | 301 | class CamoWrapper { 302 | constructor (basePath) { 303 | this._db_uri = `nedb://${path.join(basePath, 'databases/')}`; 304 | this._logger = getLogger(path.join(basePath, `${CONST.COMMON.APP_NAME}.log`)); 305 | this._db = null; 306 | 307 | this.connect().then(() => { 308 | this.prefs = Preferences; 309 | this.icon = Icon; 310 | this.application = Application; 311 | this.condition = Condition; 312 | this.rule = Rule; 313 | }); 314 | }; 315 | 316 | connect () { 317 | return connect(this._db_uri).then( 318 | db => { 319 | this._logger.info('Connected to database'); 320 | this._db = db; 321 | return this._db; 322 | }, 323 | error => { 324 | this._logger.error(error); 325 | return Promise.reject(error); 326 | } 327 | ); 328 | }; 329 | 330 | init () { 331 | return Promise.all([ 332 | this.prefs.init(this._logger) 333 | ]); 334 | }; 335 | } 336 | 337 | module.exports = { 338 | 'Icon': Icon, 339 | 'Condition': Condition, 340 | 'Rule': Rule, 341 | 'Application': Application, 342 | 'Preferences': Preferences, 343 | 'CamoWrapper': CamoWrapper 344 | }; 345 | -------------------------------------------------------------------------------- /src/app/js/evaluators.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const url = require('url'); 4 | const utils = require('./utils'); 5 | const CONST = require('./constants'); 6 | 7 | class BaseOperator { 8 | constructor (val) { 9 | this.val = val.trim().toLowerCase(); 10 | } 11 | 12 | _evaluate (val) { 13 | return false; 14 | } 15 | 16 | evaluate (val) { 17 | if (val !== null && val !== undefined) { 18 | return this._evaluate(val.trim().toLowerCase()); 19 | } 20 | return false; 21 | } 22 | 23 | static get uid () { 24 | return null; 25 | } 26 | } 27 | 28 | class OperatorIs extends BaseOperator { 29 | _evaluate (val) { 30 | return this.val === val; 31 | } 32 | static get uid () { 33 | return CONST.CONDITION_OPERATOR.IS; 34 | } 35 | } 36 | 37 | class OperatorIsNot extends BaseOperator { 38 | _evaluate (val) { 39 | return this.val !== val; 40 | } 41 | static get uid () { 42 | return CONST.CONDITION_OPERATOR.IS_NOT; 43 | } 44 | } 45 | 46 | class OperatorContains extends BaseOperator { 47 | _evaluate (val) { 48 | return val.indexOf(this.val) > -1; 49 | } 50 | static get uid () { 51 | return CONST.CONDITION_OPERATOR.CONTAINS; 52 | } 53 | } 54 | 55 | class OperatorNotContains extends BaseOperator { 56 | _evaluate (val) { 57 | return val.indexOf(this.val) === -1; 58 | } 59 | static get uid () { 60 | return CONST.CONDITION_OPERATOR.NOT_CONTAINS; 61 | } 62 | } 63 | 64 | class OperatorStartsWith extends BaseOperator { 65 | _evaluate (val) { 66 | return new RegExp('^' + this.val, 'i').test(val); 67 | } 68 | static get uid () { 69 | return CONST.CONDITION_OPERATOR.STARTS_WITH; 70 | } 71 | } 72 | 73 | class OperatorNotStartsWith extends BaseOperator { 74 | _evaluate (val) { 75 | return !(new RegExp('^' + this.val, 'i').test(val)); 76 | } 77 | static get uid () { 78 | return CONST.CONDITION_OPERATOR.NOT_STARTS_WITH; 79 | } 80 | } 81 | 82 | class OperatorEndsWith extends BaseOperator { 83 | _evaluate (val) { 84 | return new RegExp(this.val + '$', 'i').test(val); 85 | } 86 | static get uid () { 87 | return CONST.CONDITION_OPERATOR.ENDS_WITH; 88 | } 89 | } 90 | 91 | class OperatorNotEndsWith extends BaseOperator { 92 | _evaluate (val) { 93 | return !(new RegExp(this.val + '$', 'i').test(val)); 94 | } 95 | static get uid () { 96 | return CONST.CONDITION_OPERATOR.NOT_ENDS_WITH; 97 | } 98 | } 99 | 100 | class OperatorRegexp extends BaseOperator { 101 | _evaluate (val) { 102 | return new RegExp(this.val, 'i').test(val); 103 | } 104 | static get uid () { 105 | return CONST.CONDITION_OPERATOR.REGEX; 106 | } 107 | } 108 | 109 | function getConditionOperators () { 110 | let d = {}; 111 | for (let op of [OperatorIs, OperatorContains, OperatorStartsWith, OperatorEndsWith, 112 | OperatorIsNot, OperatorNotContains, OperatorNotStartsWith, OperatorNotEndsWith, 113 | OperatorRegexp]) { 114 | d[op.uid] = op; 115 | } 116 | return d; 117 | } 118 | 119 | class OperandApp { 120 | static get uid () { 121 | return CONST.CONDITION_OPERAND.APP; 122 | } 123 | 124 | static get supportedOperators () { 125 | return [OperatorIs.uid, OperatorIsNot.uid]; 126 | } 127 | } 128 | 129 | class OperandHost { 130 | static get uid () { 131 | return CONST.CONDITION_OPERAND.HOST; 132 | } 133 | 134 | static get supportedOperators () { 135 | return [ 136 | OperatorIs.uid, OperatorIsNot.uid, OperatorContains.uid, 137 | OperatorNotContains.uid, OperatorStartsWith.uid, OperatorNotStartsWith.uid, 138 | OperatorEndsWith.uid, OperatorNotEndsWith.uid 139 | ]; 140 | } 141 | } 142 | 143 | class OperandScheme { 144 | static get uid () { 145 | return CONST.CONDITION_OPERAND.SCHEME; 146 | } 147 | 148 | static get supportedOperators () { 149 | return [OperatorIs.uid, OperatorIsNot.uid]; 150 | } 151 | } 152 | 153 | class OperandPath { 154 | static get uid () { 155 | return CONST.CONDITION_OPERAND.PATH; 156 | } 157 | 158 | static get supportedOperators () { 159 | return [ 160 | OperatorIs.uid, OperatorIsNot.uid, OperatorContains.uid, 161 | OperatorNotContains.uid, OperatorStartsWith.uid, OperatorNotStartsWith.uid, 162 | OperatorEndsWith.uid, OperatorNotEndsWith.uid 163 | ]; 164 | } 165 | } 166 | 167 | class OperandPort { 168 | static get uid () { 169 | return CONST.CONDITION_OPERAND.PORT; 170 | } 171 | 172 | static get supportedOperators () { 173 | return [OperatorIs.uid, OperatorIsNot.uid]; 174 | } 175 | } 176 | 177 | class OperandExtension { 178 | static get uid () { 179 | return CONST.CONDITION_OPERAND.EXTENSION; 180 | } 181 | 182 | static get supportedOperators () { 183 | return [OperatorIs.uid, OperatorIsNot.uid]; 184 | } 185 | } 186 | 187 | class OperandUrl { 188 | static get uid () { 189 | return CONST.CONDITION_OPERAND.URL; 190 | } 191 | 192 | static get supportedOperators () { 193 | return [OperatorRegexp.uid]; 194 | } 195 | } 196 | 197 | function getConditionOperands () { 198 | let d = {}; 199 | // d[OperandApp.uid] = OperandApp; 200 | d[OperandHost.uid] = OperandHost; 201 | d[OperandScheme.uid] = OperandScheme; 202 | d[OperandPath.uid] = OperandPath; 203 | d[OperandPort.uid] = OperandPort; 204 | d[OperandUrl.uid] = OperandUrl; 205 | // d[OperandExtension.uid] = OperandExtension; 206 | return d; 207 | } 208 | 209 | function getConditionOperandsObject () { 210 | let operands = {}; 211 | 212 | for (let [key, value] of utils.entries(getConditionOperands())) { 213 | operands[key] = value.supportedOperators; 214 | } 215 | return operands; 216 | } 217 | 218 | class RuleEvaluator { 219 | constructor (rule) { 220 | this.rule = rule; 221 | } 222 | 223 | _evaluateCondition (conditionToEvaluate, objectToEvaluate) { 224 | let operand = getConditionOperands()[conditionToEvaluate.operand]; 225 | let CondClass = getConditionOperators()[conditionToEvaluate.operator]; 226 | let cond = new CondClass(conditionToEvaluate.text); 227 | let value = objectToEvaluate[operand.uid]; 228 | return cond.evaluate(value); 229 | } 230 | 231 | _any (objectToEvaluate) { 232 | let result = false; 233 | for (let condition of this.rule.conditions) { 234 | result = this._evaluateCondition(condition, objectToEvaluate); 235 | if (result) { 236 | break; 237 | } 238 | } 239 | return result; 240 | } 241 | 242 | _all (objectToEvaluate) { 243 | let result = true; 244 | for (let condition of this.rule.conditions) { 245 | result = this._evaluateCondition(condition, objectToEvaluate); 246 | if (!result) { 247 | break; 248 | } 249 | } 250 | return result; 251 | } 252 | 253 | evaluate (objectToEvaluate) { 254 | if (this.rule.operator === CONST.RULE_OPERATOR.ANY) { 255 | return this._any(objectToEvaluate); 256 | } 257 | return this._all(objectToEvaluate); 258 | } 259 | } 260 | 261 | function crateObjectToEvaluate (value, event) { 262 | let parsedUrl = url.parse(value); 263 | let result = {}; 264 | // > url.parse('http://google.com:8080') 265 | // Url { 266 | // protocol: 'http:', 267 | // slashes: true, 268 | // auth: null, 269 | // host: 'google.com:8080', 270 | // port: '8080', 271 | // hostname: 'google.com', 272 | // hash: null, 273 | // search: null, 274 | // query: null, 275 | // pathname: '/', 276 | // path: '/', 277 | // href: 'http://google.com:8080/' } 278 | // > url.parse('http://google.com:8080/') 279 | // Url { 280 | // protocol: 'http:', 281 | // slashes: true, 282 | // auth: null, 283 | // host: 'google.com:8080', 284 | // port: '8080', 285 | // hostname: 'google.com', 286 | // hash: null, 287 | // search: null, 288 | // query: null, 289 | // pathname: '/', 290 | // path: '/', 291 | // href: 'http://google.com:8080/' } 292 | result[OperandUrl.uid] = value; 293 | result[OperandHost.uid] = parsedUrl.hostname; 294 | result[OperandScheme.uid] = parsedUrl.protocol; 295 | result[OperandPath.uid] = parsedUrl.path; 296 | result[OperandPort.uid] = parsedUrl.port; 297 | 298 | // TODO 299 | // result[OperandApp.uid] = null; 300 | // result[OperandExtension.uid] = null; 301 | 302 | return result; 303 | } 304 | 305 | function evaluateRule (rule, objectToEvaluate) { 306 | return new RuleEvaluator(rule).evaluate(objectToEvaluate); 307 | } 308 | 309 | function evaluateRules (rules, url, event) { 310 | let objectToEvaluate = crateObjectToEvaluate(url, event); 311 | for (let i = 0; i < rules.length; i++) { 312 | let rule = rules[i]; 313 | if (evaluateRule(rule, objectToEvaluate)) { 314 | return rule; 315 | } 316 | } 317 | return null; 318 | } 319 | 320 | module.exports = { 321 | '_OperatorIs': OperatorIs, 322 | '_OperatorIsNot': OperatorIsNot, 323 | '_OperatorContains': OperatorContains, 324 | '_OperatorNotContains': OperatorNotContains, 325 | '_OperatorStartsWith': OperatorStartsWith, 326 | '_OperatorNotStartsWith': OperatorNotStartsWith, 327 | '_OperatorEndsWith': OperatorEndsWith, 328 | '_OperatorNotEndsWith': OperatorNotEndsWith, 329 | '_OperatorRegexp': OperatorRegexp, 330 | 331 | '_OperandApp': OperandApp, 332 | '_OperandHost': OperandHost, 333 | '_OperandScheme': OperandScheme, 334 | '_OperandPath': OperandPath, 335 | '_OperandPort': OperandPort, 336 | '_OperandExtension': OperandExtension, 337 | '_OperandUrL': OperandUrl, 338 | 339 | '_RuleEvaluator': RuleEvaluator, 340 | 341 | 'crateObjectToEvaluate': crateObjectToEvaluate, 342 | 'evaluateRule': evaluateRule, 343 | 'evaluateRules': evaluateRules, 344 | 'getConditionOperators': getConditionOperators, 345 | 'getConditionOperands': getConditionOperands, 346 | 'getConditionOperandsObject': getConditionOperandsObject 347 | }; 348 | -------------------------------------------------------------------------------- /src/app/js/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | winston.emitErrs = true; 3 | 4 | function getLogger (logFilename) { 5 | return new winston.Logger({ 6 | transports: [ 7 | new winston.transports.File({ 8 | level: 'info', 9 | filename: logFilename, 10 | handleExceptions: true, 11 | json: false, 12 | maxsize: 5242880, // 5MB 13 | maxFiles: 5, 14 | colorize: false 15 | }) 16 | ], 17 | exitOnError: false 18 | }); 19 | } 20 | 21 | module.exports = { 22 | 'getLogger': getLogger 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/js/open2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const exec = require('child_process').exec; 4 | 5 | function escape (s) { 6 | // eslint-disable-next-line no-useless-escape 7 | return s.replace(/"/g, '\\\"'); 8 | } 9 | 10 | /** 11 | * open a file or uri using the default application for the file type. 12 | * 13 | * @return {ChildProcess} - the child process object. 14 | * @param {string} target - the file/uri to open. 15 | * @param {string} app - (optional) the application to be used to open the 16 | * file (for example, "chrome", "firefox") 17 | * @param mode 18 | * @param options 19 | * @param args 20 | * an error object that contains a property 'code' with the exit 21 | * code of the process. 22 | */ 23 | function open2 (target, app, mode, options = null, args = null) { 24 | app = escape(app); 25 | 26 | let cmd = `open ${mode} "${app}"`; 27 | 28 | if (options) { 29 | cmd = `${cmd} ${options}`; 30 | } 31 | 32 | cmd = `${cmd} "${target}"`; 33 | 34 | if (args) { 35 | cmd = `${cmd} −−args ${args}`; 36 | } 37 | 38 | return exec(cmd); 39 | } 40 | 41 | module.exports = open2; 42 | -------------------------------------------------------------------------------- /src/app/js/signals.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const GENERAL = { 4 | TEST_URL: 'test-url', 5 | LOAD_URL: 'load-url', 6 | OPEN_URL: 'open-url', 7 | SHOW_MAN_PAGE: 'show-man-page', 8 | ERROR: 'general-error' 9 | }; 10 | 11 | const CONFIG = { 12 | READ_STATUS: 'read-status', 13 | UPDATE_STATUS: 'update-status', 14 | READ_IS_USE_DEFAULT: 'read-is-use-default', 15 | UPDATE_IS_USE_DEFAULT: 'update-is-use-default', 16 | TOGGLE_APP_STATUS: 'toggle-app-status', 17 | RESET_ALL: 'reset-all' 18 | }; 19 | 20 | const APPLICATION = { 21 | CREATE: 'create-applications', 22 | READ: 'get-applications', 23 | UPDATE: 'update-applications', 24 | DELETE: 'delete-applications', 25 | 26 | CREATE_ONE: 'create-application', 27 | READ_ONE: 'get-application', 28 | UPDATE_ONE: 'update-application', 29 | DELETE_ONE: 'delete-application', 30 | 31 | CLEAR: 'clear-applications', 32 | UPDATE_DEFAULT: 'update-default-application' 33 | }; 34 | 35 | const ICON = { 36 | CREATE: 'create-icons', 37 | READ: 'get-icons', 38 | UPDATE: 'update-icons', 39 | DELETE: 'delete-icons', 40 | 41 | CREATE_ONE: 'create-icon', 42 | READ_ONE: 'get-icon', 43 | UPDATE_ONE: 'update-icon', 44 | DELETE_ONE: 'delete-icon', 45 | 46 | CLEAR: 'clear-icons' 47 | }; 48 | 49 | const RULE = { 50 | CREATE: 'create-rules', 51 | READ: 'get-rules', 52 | UPDATE: 'update-rules', 53 | DELETE: 'delete-rules', 54 | 55 | CREATE_ONE: 'create-rule', 56 | READ_ONE: 'get-rule', 57 | UPDATE_ONE: 'update-rule', 58 | DELETE_ONE: 'delete-rule', 59 | 60 | CLEAR: 'clear-rules' 61 | }; 62 | 63 | function request (signal) { 64 | return `request-${signal}`; 65 | } 66 | 67 | function response (signal) { 68 | return `response-${signal}`; 69 | } 70 | 71 | module.exports = { 72 | 'CONFIG': CONFIG, 73 | 'APPLICATION': APPLICATION, 74 | 'RULE': RULE, 75 | 'ICON': ICON, 76 | 'GENERAL': GENERAL, 77 | 'request': request, 78 | 'response': response 79 | }; 80 | -------------------------------------------------------------------------------- /src/app/js/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const plist = require('plist'); 6 | const glob = require('glob'); 7 | 8 | function* entries (obj) { 9 | for (let key of Object.keys(obj)) { 10 | yield [key, obj[key]]; 11 | } 12 | } 13 | 14 | function objectValues (obj) { 15 | return Object.keys(obj).map(key => obj[key]); 16 | } 17 | 18 | function _isInArray (arr, val) { 19 | return arr.some(arrVal => { 20 | return val === arrVal; 21 | }); 22 | } 23 | 24 | function getAppInfo (appPath, onlyBrowsers = true) { 25 | let plistPath = path.join(appPath, 'Contents', 'Info.plist'); 26 | let plistParsed = plist.parse(fs.readFileSync(plistPath, 'utf8')); 27 | let urlTypes = plistParsed.CFBundleURLTypes || []; 28 | let wantedSchemes = ['http', 'https', 'ftp', 'file']; 29 | let result = { 'is_browser': false, schemes: [] }; 30 | 31 | for (let i = 0; i < urlTypes.length; i++) { 32 | let availableSchemes = urlTypes[i].CFBundleURLSchemes || []; 33 | for (let j = 0; j < wantedSchemes.length; j++) { 34 | let wantedScheme = wantedSchemes[i]; 35 | if (_isInArray(availableSchemes, wantedScheme) && result.schemes.indexOf(wantedScheme) === -1) { 36 | result.schemes.push(wantedScheme); 37 | result.is_browser = true; 38 | } 39 | } 40 | } 41 | if (result.is_browser || onlyBrowsers === false) { 42 | result['identifier'] = plistParsed.CFBundleIdentifier; 43 | result['display_name'] = plistParsed.CFBundleDisplayName || plistParsed.CFBundleName; 44 | result['name'] = plistParsed.CFBundleName; 45 | result['executable'] = plistParsed.CFBundleExecutable; 46 | let iconFie = plistParsed.CFBundleIconFile.endsWith('.icns') ? plistParsed.CFBundleIconFile : `${plistParsed.CFBundleIconFile}.icns`; 47 | result['icns'] = path.join(appPath, 'Contents', 'Resources', iconFie); 48 | result['path'] = appPath; 49 | } 50 | return result; 51 | } 52 | 53 | function getApps (appsRoot = '/Applications') { 54 | let result = {errors: [], results: []}; 55 | let appsList = glob('*.app', {'cwd': appsRoot, 'sync': true}); 56 | for (let i = 0; i < appsList.length; i++) { 57 | let appPath = path.join(appsRoot, appsList[i]); 58 | try { 59 | let appInfo = getAppInfo(appPath); 60 | if (appInfo.is_browser) { 61 | result.results.push(appInfo); 62 | } 63 | } catch (e) { 64 | result.errors.push(e); 65 | } 66 | } 67 | return result; 68 | } 69 | 70 | module.exports = { 71 | 'entries': entries, 72 | 'objectValues': objectValues, 73 | 'getApps': getApps, 74 | 'getAppInfo': getAppInfo 75 | }; 76 | -------------------------------------------------------------------------------- /src/app/templates/edit_application.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 11 |
12 |
13 |
14 |
15 |
16 | 22 | 32 |
33 |
34 |
35 |
36 |
37 |
38 | 39 |
40 |
41 | 46 |
47 |
48 |
49 |
50 | 51 |
52 |
53 | 58 |
59 |
60 |
61 |
62 | 63 |
64 |
65 | 70 |
71 |
72 |
73 |
74 | 75 |
76 |
77 | 82 |
83 |
84 |
85 |
86 | -------------------------------------------------------------------------------- /src/app/templates/edit_condition.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 11 |
12 |
13 | 19 |
20 |
21 | 24 |
25 |
26 |
27 | 35 | 42 | 49 |
50 |
51 |
52 |
53 |
54 | -------------------------------------------------------------------------------- /src/app/templates/edit_rule.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 10 | 20 |
21 |
22 |
23 |
24 | If 25 | 29 | of the following conditions are met 30 |
31 |
32 | 35 | 36 |
37 |
38 |
39 |
40 | Use this application: 41 | 46 |
47 |
48 | 54 |
55 |
56 | 62 |
63 |
64 | 70 |
71 |
72 |
73 | 74 |
75 |
76 | 80 |
81 |
82 | 85 | 86 |
87 |
88 |
89 |
90 |
91 | 96 | 98 | 99 |
100 |
101 | 107 |
108 |
109 |
110 |
111 | -------------------------------------------------------------------------------- /src/app/templates/edit_settings.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | Default web browser: 6 | 11 |
12 |
13 | 19 |
20 |
21 | 27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /src/app/templates/list_of_applications.html: -------------------------------------------------------------------------------- 1 | 25 | 26 | -------------------------------------------------------------------------------- /src/app/templates/list_of_rules.html: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /src/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "src", 3 | "private": true, 4 | "moduleType": [ 5 | "es6", 6 | "globals", 7 | "node", 8 | "yui" 9 | ], 10 | "ignore": [ 11 | "**/.*", 12 | "node_modules", 13 | "bower_components", 14 | "test", 15 | "tests" 16 | ], 17 | "dependencies": { 18 | "angular": "^1.6.1", 19 | "angular-material": "^1.1.1", 20 | "angular-sanitize": "^1.6.1", 21 | "photon": "^0.1.2-alpha", 22 | "flexboxgrid": "^6.3.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/e2e/main.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const Application = require('spectron').Application; 6 | const chai = require('chai'); 7 | const chaiAsPromised = require('chai-as-promised'); 8 | const electronPath = require('electron'); 9 | 10 | const TIMEOUT = process.env.CI ? 30000 : 10000; 11 | const ACTION_TIMEOUT = 1500; 12 | const MAIN_JS = path.resolve(path.join(__dirname, '..', '..', 'main.js')); 13 | const USER_DATA_PATH = path.join(process.env.HOME, 'Library', 'Application Support', 'Electron'); 14 | const USER_DATA_PATH_DB = path.join(USER_DATA_PATH, 'databases'); 15 | const APPS_ROOT_FAKE = path.resolve(path.join(__dirname, '..', 'test_data', 'Applications')); 16 | 17 | let deleteDatabaseFile = function (fpath) { 18 | if (fs.existsSync(fpath) && fs.statSync(fpath).isFile()) { 19 | fs.unlinkSync(fpath); 20 | } 21 | }; 22 | 23 | let clearDatabaseFiles = function () { 24 | for (let fp of [ 25 | path.join(USER_DATA_PATH_DB, 'applications.db'), 26 | path.join(USER_DATA_PATH_DB, 'icons.db'), 27 | path.join(USER_DATA_PATH_DB, 'rules.db'), 28 | path.join(USER_DATA_PATH_DB, 'settings.db') 29 | ]) { 30 | deleteDatabaseFile(fp); 31 | } 32 | }; 33 | 34 | global.before(function () { 35 | chai.should(); 36 | chai.use(chaiAsPromised); 37 | }); 38 | 39 | describe('BrowserDispatcher', function () { 40 | this.timeout(TIMEOUT); 41 | 42 | before(function () { 43 | process.env.BROWSER_DISPATCHER_APPS_ROOT = APPS_ROOT_FAKE; 44 | process.env.RUNNING_IN_SPECTRON = true; 45 | clearDatabaseFiles(); 46 | }); 47 | 48 | after(function () { 49 | delete process.env.BROWSER_DISPATCHER_APPS_ROOT; 50 | delete process.env.RUNNING_IN_SPECTRON; 51 | clearDatabaseFiles(); 52 | }); 53 | 54 | beforeEach(function () { 55 | this.app = new Application({ 56 | path: electronPath, 57 | args: [MAIN_JS] 58 | }); 59 | chaiAsPromised.transferPromiseness = this.app.transferPromiseness; 60 | return this.app.start(); 61 | }); 62 | 63 | afterEach(function () { 64 | deleteDatabaseFile(path.join(USER_DATA_PATH_DB, 'rules.db')); 65 | if (this.app && this.app.isRunning()) { 66 | return this.app.stop(); 67 | } 68 | }); 69 | 70 | it('starts with main window shown', function (done) { 71 | this.app.client 72 | .waitUntilWindowLoaded() 73 | .windowByIndex(0).browserWindow.isVisible().should.eventually.be.true 74 | .windowByIndex(0).browserWindow.getTitle().should.eventually.equal('Browser Dispatcher') 75 | .notify(done); 76 | }); 77 | 78 | it('has no rules predefined', function (done) { 79 | this.app.client 80 | .isVisible('#noRules').should.eventually.be.true 81 | .getText('#noRules').should.eventually.equal('There are no rules configured') 82 | .notify(done); 83 | }); 84 | 85 | it('automatically loads list of available applications', function (done) { 86 | this.app.client 87 | .waitUntilWindowLoaded() 88 | .click('#toolbarActionReloadApps') 89 | .waitForVisible('#appsList', ACTION_TIMEOUT) 90 | .isExisting('#noApps').should.eventually.be.false 91 | .getText('#appItemDisplayName_0').should.eventually.be.oneOf(['Safari', 'Google Chrome']) 92 | .getText('#appItemIdentifier_0').should.eventually.be.oneOf(['com.apple.Safari', 'com.google.Chrome']) 93 | .getText('#appItemDisplayName_1').should.eventually.be.oneOf(['Safari', 'Google Chrome']) 94 | .getText('#appItemIdentifier_1').should.eventually.be.oneOf(['com.apple.Safari', 'com.google.Chrome']) 95 | .isExisting('#appDisplayName_2').should.eventually.be.false 96 | .notify(done); 97 | }); 98 | 99 | it('adds simple rule', function (done) { 100 | let ruleName = 'My Test Rule'; 101 | let conditionText = 'google.com'; 102 | this.app.client 103 | .waitUntilWindowLoaded() 104 | .waitForVisible('#noRules', ACTION_TIMEOUT).should.be.fulfilled 105 | .click('#toolbarActionAddNew') 106 | .waitForVisible('#editRule', ACTION_TIMEOUT).should.be.fulfilled 107 | .setValue('#editRuleName', ruleName) 108 | .getValue('#editRuleName').should.eventually.equal(ruleName) 109 | .setValue('#editConditionText_0', conditionText) 110 | .getValue('#editConditionText_0').should.eventually.equal(conditionText) 111 | .click('#toolbarActionSave') 112 | .waitForVisible('#rulesList', ACTION_TIMEOUT).should.be.fulfilled 113 | .getText('#ruleItemName_0').should.eventually.equal(ruleName) 114 | .getText('#ruleItemConditions_0').should.eventually.equal('1 condition') 115 | .getAttribute('#ruleItemAppLogo_0', 'alt').should.eventually.equal('Safari logo').and 116 | .notify(done); 117 | }); 118 | 119 | it('configures advanced rule', function (done) { 120 | let ruleName = 'My Test Rule'; 121 | this.app.client 122 | .waitUntilWindowLoaded() 123 | .waitForVisible('#noRules', ACTION_TIMEOUT).should.be.fulfilled 124 | .click('#toolbarActionAddNew') 125 | .waitForVisible('#editRule', ACTION_TIMEOUT).should.be.fulfilled 126 | .selectByValue('#editRuleOperator', 'all') 127 | .setValue('#editRuleName', ruleName) 128 | .selectByValue('#editConditionOperand_0', 'host') 129 | .selectByValue('#editConditionOperator_0', 'is') 130 | .setValue('#editConditionText_0', 'github.com') 131 | .click('#editConditionActionAdd_0') 132 | .waitForVisible('#editConditionText_1', ACTION_TIMEOUT).should.be.fulfilled 133 | .selectByValue('#editConditionOperand_1', 'path') 134 | .selectByValue('#editConditionOperator_1', 'starts_with') 135 | .setValue('#editConditionText_1', '/andriyko/') 136 | .selectByVisibleText('#editRuleApplication', 'Google Chrome') 137 | .click('#editRuleAdvancedSettingsOpenNewInstance') 138 | .click('#toolbarActionSave') 139 | .waitForVisible('#rulesList', ACTION_TIMEOUT).should.be.fulfilled 140 | .getText('#ruleItemName_0').should.eventually.equal(ruleName) 141 | .getText('#ruleItemConditions_0').should.eventually.equal('All 2 conditions must match') 142 | .getAttribute('#ruleItemAppLogo_0', 'alt').should.eventually.equal('Google Chrome logo') 143 | .click('#ruleItem_0') 144 | .waitForVisible('#editRule', ACTION_TIMEOUT).should.be.fulfilled 145 | .getValue('#editRuleName').should.eventually.equal(ruleName) 146 | .getValue('#editRuleOperator').should.eventually.equal('all') 147 | .getValue('#editConditionOperand_0').should.eventually.equal('host') 148 | .getValue('#editConditionOperator_0').should.eventually.equal('is') 149 | .getValue('#editConditionText_0').should.eventually.equal('github.com') 150 | .getValue('#editConditionOperand_1').should.eventually.equal('path') 151 | .getValue('#editConditionOperator_1').should.eventually.equal('starts_with') 152 | .getValue('#editConditionText_1').should.eventually.equal('/andriyko/') 153 | .getValue('#editRuleAdvancedSettingsOpenNewInstance').should.eventually.equal('on') 154 | .notify(done); 155 | }); 156 | 157 | it('edits rule', function (done) { 158 | let ruleName = 'My Test Rule'; 159 | let conditionText = 'google.com'; 160 | this.app.client 161 | .waitUntilWindowLoaded() 162 | .waitForVisible('#noRules', ACTION_TIMEOUT).should.be.fulfilled 163 | .click('#toolbarActionAddNew') 164 | .waitForVisible('#editRule', ACTION_TIMEOUT).should.be.fulfilled 165 | .setValue('#editRuleName', ruleName) 166 | .getValue('#editRuleName').should.eventually.equal(ruleName) 167 | .setValue('#editConditionText_0', conditionText) 168 | .getValue('#editConditionText_0').should.eventually.equal(conditionText) 169 | .click('#toolbarActionSave') 170 | .waitForVisible('#rulesList', ACTION_TIMEOUT).should.be.fulfilled 171 | .click('#ruleItem_0') 172 | .waitForVisible('#editRule', ACTION_TIMEOUT).should.be.fulfilled 173 | .setValue('#editRuleName', `${ruleName} Edited`) 174 | .getValue('#editRuleName').should.eventually.equal(`${ruleName} Edited`) 175 | .setValue('#editConditionText_0', `${conditionText}.ua`) 176 | .getValue('#editConditionText_0').should.eventually.equal(`${conditionText}.ua`) 177 | .click('#editConditionActionAdd_0') 178 | .setValue('#editConditionText_1', conditionText) 179 | .getValue('#editConditionText_1').should.eventually.equal(conditionText) 180 | .click('#toolbarActionSave') 181 | .waitForVisible('#rulesList', ACTION_TIMEOUT).should.be.fulfilled 182 | .getText('#ruleItemName_0').should.eventually.equal(`${ruleName} Edited`) 183 | .getText('#ruleItemConditions_0').should.eventually.equal('Any 2 conditions must match').and 184 | .notify(done); 185 | }); 186 | 187 | it('deletes rule', function (done) { 188 | let ruleName = 'My Test Rule'; 189 | let conditionText = 'google.com'; 190 | this.app.client 191 | .waitUntilWindowLoaded() 192 | .waitForVisible('#noRules', ACTION_TIMEOUT).should.be.fulfilled 193 | .click('#toolbarActionAddNew') 194 | .waitForVisible('#editRule', ACTION_TIMEOUT).should.be.fulfilled 195 | .setValue('#editRuleName', ruleName) 196 | .setValue('#editConditionText_0', conditionText) 197 | .click('#toolbarActionSave') 198 | .waitForVisible('#rulesList', ACTION_TIMEOUT).should.be.fulfilled 199 | .click('#ruleItem_0') 200 | .click('#toolbarActionDelete') 201 | .waitForVisible('#noRules', ACTION_TIMEOUT).should.be.fulfilled 202 | .notify(done); 203 | }); 204 | 205 | it('enables/disables the application', function (done) { 206 | this.app.client 207 | .waitUntilWindowLoaded() 208 | .click('#toolbarActionReloadSettings') 209 | .waitForVisible('#editSettings', ACTION_TIMEOUT).should.be.fulfilled 210 | .isSelected('#editSettingsAppStatus').should.eventually.be.true 211 | .isSelected('#editSettingsAppUseDefault').should.eventually.be.false 212 | .click('#editSettingsAppStatus') 213 | .click('#toolbarActionSave') 214 | .click('#toolbarActionReloadRules') 215 | .waitForVisible('#noRules', ACTION_TIMEOUT).should.be.fulfilled 216 | .click('#toolbarActionReloadSettings') 217 | .isSelected('#editSettingsAppStatus').should.eventually.be.false 218 | .notify(done); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /tests/test_data/Applications/Calculator.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 16C30 7 | CFBundleDevelopmentRegion 8 | English 9 | CFBundleExecutable 10 | Calculator 11 | CFBundleGetInfoString 12 | 10.8, Copyright © 2001-2013, Apple Inc. 13 | CFBundleHelpBookFolder 14 | Calculator.help 15 | CFBundleHelpBookName 16 | com.apple.Calculator.help 17 | CFBundleIconFile 18 | AppIcon 19 | CFBundleIdentifier 20 | com.apple.calculator 21 | CFBundleInfoDictionaryVersion 22 | 6.0 23 | CFBundleName 24 | Calculator 25 | CFBundlePackageType 26 | APPL 27 | CFBundleShortVersionString 28 | 10.8 29 | CFBundleSignature 30 | ???? 31 | CFBundleSupportedPlatforms 32 | 33 | MacOSX 34 | 35 | CFBundleVersion 36 | 123 37 | DTCompiler 38 | com.apple.compilers.llvm.clang.1_0 39 | DTPlatformBuild 40 | 8R174l 41 | DTPlatformVersion 42 | GM 43 | DTSDKBuild 44 | 16C30 45 | DTSDKName 46 | macosx10.12internal 47 | DTXcode 48 | 0800 49 | DTXcodeBuild 50 | 8R174l 51 | LSApplicationCategoryType 52 | public.app-category.utilities 53 | LSApplicationSecondaryCategoryType 54 | public.app-category.productivity 55 | LSHasLocalizedDisplayName 56 | 57 | LSMinimumSystemVersion 58 | 10.10.0 59 | NSMainNibFile 60 | Calculator 61 | NSPrincipalClass 62 | NSApplication 63 | NSSupportsSuddenTermination 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /tests/test_data/Applications/Google Chrome.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 13F1077 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleDisplayName 10 | Google Chrome 11 | CFBundleDocumentTypes 12 | 13 | 14 | CFBundleTypeExtensions 15 | 16 | gif 17 | 18 | CFBundleTypeIconFile 19 | document.icns 20 | CFBundleTypeMIMETypes 21 | 22 | image/gif 23 | 24 | CFBundleTypeName 25 | GIF image 26 | CFBundleTypeOSTypes 27 | 28 | GIFf 29 | 30 | CFBundleTypeRole 31 | Viewer 32 | 33 | 34 | CFBundleTypeExtensions 35 | 36 | html 37 | htm 38 | 39 | CFBundleTypeIconFile 40 | document.icns 41 | CFBundleTypeMIMETypes 42 | 43 | text/html 44 | 45 | CFBundleTypeName 46 | HTML document 47 | CFBundleTypeOSTypes 48 | 49 | HTML 50 | 51 | CFBundleTypeRole 52 | Viewer 53 | 54 | 55 | CFBundleTypeExtensions 56 | 57 | xhtml 58 | 59 | CFBundleTypeIconFile 60 | document.icns 61 | CFBundleTypeMIMETypes 62 | 63 | text/xhtml 64 | 65 | CFBundleTypeName 66 | XHTML document 67 | CFBundleTypeRole 68 | Viewer 69 | 70 | 71 | CFBundleTypeExtensions 72 | 73 | js 74 | 75 | CFBundleTypeIconFile 76 | document.icns 77 | CFBundleTypeMIMETypes 78 | 79 | application/x-javascript 80 | 81 | CFBundleTypeName 82 | JavaScript script 83 | CFBundleTypeRole 84 | Viewer 85 | 86 | 87 | CFBundleTypeExtensions 88 | 89 | jpg 90 | jpeg 91 | 92 | CFBundleTypeIconFile 93 | document.icns 94 | CFBundleTypeMIMETypes 95 | 96 | image/jpeg 97 | 98 | CFBundleTypeName 99 | JPEG image 100 | CFBundleTypeOSTypes 101 | 102 | JPEG 103 | 104 | CFBundleTypeRole 105 | Viewer 106 | 107 | 108 | CFBundleTypeExtensions 109 | 110 | mhtml 111 | mht 112 | 113 | CFBundleTypeIconFile 114 | document.icns 115 | CFBundleTypeMIMETypes 116 | 117 | multipart/related 118 | application/x-mimearchive 119 | message/rfc822 120 | 121 | CFBundleTypeName 122 | MHTML document 123 | CFBundleTypeRole 124 | Viewer 125 | 126 | 127 | CFBundleTypeExtensions 128 | 129 | oga 130 | ogg 131 | 132 | CFBundleTypeIconFile 133 | document.icns 134 | CFBundleTypeMIMETypes 135 | 136 | audio/ogg 137 | 138 | CFBundleTypeName 139 | HTML5 Audio (Ogg) 140 | CFBundleTypeRole 141 | Viewer 142 | 143 | 144 | CFBundleTypeExtensions 145 | 146 | ogv 147 | 148 | CFBundleTypeIconFile 149 | document.icns 150 | CFBundleTypeMIMETypes 151 | 152 | video/ogg 153 | 154 | CFBundleTypeName 155 | HTML5 Video (Ogg) 156 | CFBundleTypeRole 157 | Viewer 158 | 159 | 160 | CFBundleTypeExtensions 161 | 162 | png 163 | 164 | CFBundleTypeIconFile 165 | document.icns 166 | CFBundleTypeMIMETypes 167 | 168 | image/png 169 | 170 | CFBundleTypeName 171 | PNG image 172 | CFBundleTypeOSTypes 173 | 174 | PNGf 175 | 176 | CFBundleTypeRole 177 | Viewer 178 | 179 | 180 | CFBundleTypeExtensions 181 | 182 | svg 183 | 184 | CFBundleTypeIconFile 185 | document.icns 186 | CFBundleTypeMIMETypes 187 | 188 | image/svg+xml 189 | 190 | CFBundleTypeName 191 | SVG document 192 | CFBundleTypeRole 193 | Viewer 194 | 195 | 196 | CFBundleTypeExtensions 197 | 198 | txt 199 | text 200 | 201 | CFBundleTypeIconFile 202 | document.icns 203 | CFBundleTypeMIMETypes 204 | 205 | text/plain 206 | 207 | CFBundleTypeName 208 | Plain text document 209 | CFBundleTypeOSTypes 210 | 211 | TEXT 212 | 213 | CFBundleTypeRole 214 | Viewer 215 | 216 | 217 | CFBundleTypeExtensions 218 | 219 | webm 220 | 221 | CFBundleTypeIconFile 222 | document.icns 223 | CFBundleTypeMIMETypes 224 | 225 | video/webm 226 | 227 | CFBundleTypeName 228 | HTML5 Video (WebM) 229 | CFBundleTypeRole 230 | Viewer 231 | 232 | 233 | CFBundleTypeExtensions 234 | 235 | webp 236 | 237 | CFBundleTypeIconFile 238 | document.icns 239 | CFBundleTypeMIMETypes 240 | 241 | image/webp 242 | 243 | CFBundleTypeName 244 | WebP image 245 | CFBundleTypeRole 246 | Viewer 247 | 248 | 249 | CFBundleTypeRole 250 | Viewer 251 | LSItemContentTypes 252 | 253 | org.chromium.extension 254 | 255 | 256 | 257 | CFBundleTypeExtensions 258 | 259 | pdf 260 | 261 | CFBundleTypeIconFile 262 | document.icns 263 | CFBundleTypeMIMETypes 264 | 265 | application/pdf 266 | 267 | CFBundleTypeName 268 | PDF Document 269 | CFBundleTypeRole 270 | Viewer 271 | 272 | 273 | CFBundleExecutable 274 | Google Chrome 275 | CFBundleIconFile 276 | app.icns 277 | CFBundleIdentifier 278 | com.google.Chrome 279 | CFBundleInfoDictionaryVersion 280 | 6.0 281 | CFBundleName 282 | Chrome 283 | CFBundlePackageType 284 | APPL 285 | CFBundleShortVersionString 286 | 55.0.2883.95 287 | CFBundleSignature 288 | rimZ 289 | CFBundleURLTypes 290 | 291 | 292 | CFBundleURLName 293 | Web site URL 294 | CFBundleURLSchemes 295 | 296 | http 297 | https 298 | 299 | 300 | 301 | CFBundleURLName 302 | FTP site URL 303 | CFBundleURLSchemes 304 | 305 | ftp 306 | 307 | 308 | 309 | CFBundleURLName 310 | Local file URL 311 | CFBundleURLSchemes 312 | 313 | file 314 | 315 | 316 | 317 | CFBundleVersion 318 | 2883.95 319 | DTCompiler 320 | com.apple.compilers.llvm.clang.1_0 321 | DTSDKBuild 322 | 10.10 323 | DTSDKName 324 | macosx10.10 325 | DTXcode 326 | 0511 327 | DTXcodeBuild 328 | 5B1008 329 | KSChannelID-full 330 | -full 331 | KSProductID 332 | com.google.Chrome 333 | KSUpdateURL 334 | https://tools.google.com/service/update2 335 | KSVersion 336 | 55.0.2883.95 337 | LSFileQuarantineEnabled 338 | 339 | LSHasLocalizedDisplayName 340 | 1 341 | LSMinimumSystemVersion 342 | 10.9.0 343 | NSAppleScriptEnabled 344 | 345 | NSPrincipalClass 346 | BrowserCrApplication 347 | NSSupportsAutomaticGraphicsSwitching 348 | 349 | NSUserActivityTypes 350 | 351 | NSUserActivityTypeBrowsingWeb 352 | 353 | NSUserNotificationAlertStyle 354 | banner 355 | OSAScriptingDefinition 356 | scripting.sdef 357 | SCMRevision 358 | a97b1aab33877677c4734c58ed41246aa40942b7-refs/branch-heads/2883@{#738} 359 | UTExportedTypeDeclarations 360 | 361 | 362 | UTTypeConformsTo 363 | 364 | public.data 365 | 366 | UTTypeDescription 367 | Chromium Extra 368 | UTTypeIdentifier 369 | org.chromium.extension 370 | UTTypeTagSpecification 371 | 372 | public.filename-extension 373 | 374 | crx 375 | 376 | 377 | 378 | 379 | 380 | 381 | -------------------------------------------------------------------------------- /tests/test_data/Applications/Google Chrome.app/Contents/Resources/app.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/tests/test_data/Applications/Google Chrome.app/Contents/Resources/app.icns -------------------------------------------------------------------------------- /tests/test_data/Applications/Safari.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Application-Group 6 | 7 | dot-mac 8 | InternetAccounts 9 | 10 | BuildMachineOSBuild 11 | 16C56 12 | CFBundleDevelopmentRegion 13 | English 14 | CFBundleDocumentTypes 15 | 16 | 17 | CFBundleTypeExtensions 18 | 19 | css 20 | 21 | CFBundleTypeIconFile 22 | css.icns 23 | CFBundleTypeMIMETypes 24 | 25 | text/css 26 | 27 | CFBundleTypeName 28 | CSS style sheet 29 | CFBundleTypeRole 30 | Viewer 31 | LSHandlerRank 32 | Default 33 | NSDocumentClass 34 | BrowserDocument 35 | 36 | 37 | CFBundleTypeExtensions 38 | 39 | pdf 40 | 41 | CFBundleTypeIconFile 42 | pdf.icns 43 | CFBundleTypeMIMETypes 44 | 45 | application/pdf 46 | 47 | CFBundleTypeName 48 | PDF document 49 | CFBundleTypeRole 50 | Viewer 51 | LSHandlerRank 52 | Default 53 | NSDocumentClass 54 | BrowserDocument 55 | 56 | 57 | CFBundleTypeExtensions 58 | 59 | webarchive 60 | 61 | CFBundleTypeIconFile 62 | webarchive.icns 63 | CFBundleTypeMIMETypes 64 | 65 | application/x-webarchive 66 | 67 | CFBundleTypeName 68 | Web archive 69 | CFBundleTypeRole 70 | Viewer 71 | ICExtension 72 | ARCHIVE 73 | LSHandlerRank 74 | Default 75 | LSIsAppleDefaultForType 76 | 77 | NSDocumentClass 78 | BrowserDocument 79 | 80 | 81 | CFBundleTypeExtensions 82 | 83 | webbookmark 84 | 85 | CFBundleTypeIconFile 86 | webbookmark.icns 87 | CFBundleTypeName 88 | Safari bookmark 89 | CFBundleTypeRole 90 | Viewer 91 | LSHandlerRank 92 | Default 93 | NSDocumentClass 94 | BrowserDocument 95 | 96 | 97 | CFBundleTypeExtensions 98 | 99 | webhistory 100 | 101 | CFBundleTypeIconFile 102 | webhistory.icns 103 | CFBundleTypeName 104 | Safari history item 105 | CFBundleTypeRole 106 | Viewer 107 | LSHandlerRank 108 | Default 109 | NSDocumentClass 110 | BrowserDocument 111 | 112 | 113 | CFBundleTypeExtensions 114 | 115 | webloc 116 | 117 | CFBundleTypeIconFile 118 | webloc.icns 119 | CFBundleTypeName 120 | Web internet location 121 | CFBundleTypeOSTypes 122 | 123 | ilht 124 | 125 | CFBundleTypeRole 126 | Viewer 127 | LSHandlerRank 128 | Default 129 | NSDocumentClass 130 | BrowserDocument 131 | 132 | 133 | CFBundleTypeExtensions 134 | 135 | download 136 | 137 | CFBundleTypeIconFile 138 | download10.icns 139 | CFBundleTypeName 140 | Safari download 141 | CFBundleTypeRole 142 | Editor 143 | LSHandlerRank 144 | Default 145 | LSTypeIsPackage 146 | 147 | NSDocumentClass 148 | BrowserDocument 149 | 150 | 151 | CFBundleTypeExtensions 152 | 153 | safariextz 154 | 155 | CFBundleTypeIconFile 156 | safariextz.icns 157 | CFBundleTypeMIMETypes 158 | 159 | application/x-safari-extension 160 | 161 | CFBundleTypeName 162 | Safari extension 163 | CFBundleTypeRole 164 | Viewer 165 | LSHandlerRank 166 | Owner 167 | LSTypeIsPackage 168 | 169 | NSDocumentClass 170 | BrowserDocument 171 | 172 | 173 | CFBundleTypeExtensions 174 | 175 | gif 176 | 177 | CFBundleTypeIconFile 178 | gif.icns 179 | CFBundleTypeMIMETypes 180 | 181 | image/gif 182 | 183 | CFBundleTypeName 184 | GIF image 185 | CFBundleTypeOSTypes 186 | 187 | GIFf 188 | 189 | CFBundleTypeRole 190 | Viewer 191 | LSHandlerRank 192 | Default 193 | NSDocumentClass 194 | BrowserDocument 195 | 196 | 197 | CFBundleTypeExtensions 198 | 199 | html 200 | htm 201 | shtml 202 | jhtml 203 | 204 | CFBundleTypeIconFile 205 | html.icns 206 | CFBundleTypeMIMETypes 207 | 208 | text/html 209 | 210 | CFBundleTypeName 211 | HTML document 212 | CFBundleTypeOSTypes 213 | 214 | HTML 215 | 216 | CFBundleTypeRole 217 | Viewer 218 | ICExtension 219 | HTML 220 | LSHandlerRank 221 | Default 222 | LSIsAppleDefaultForType 223 | 224 | NSDocumentClass 225 | BrowserDocument 226 | 227 | 228 | CFBundleTypeExtensions 229 | 230 | js 231 | 232 | CFBundleTypeIconFile 233 | js.icns 234 | CFBundleTypeMIMETypes 235 | 236 | application/x-javascript 237 | 238 | CFBundleTypeName 239 | JavaScript script 240 | CFBundleTypeRole 241 | Viewer 242 | LSHandlerRank 243 | Default 244 | NSDocumentClass 245 | BrowserDocument 246 | 247 | 248 | CFBundleTypeExtensions 249 | 250 | jpg 251 | jpeg 252 | 253 | CFBundleTypeIconFile 254 | jpeg.icns 255 | CFBundleTypeMIMETypes 256 | 257 | image/jpeg 258 | 259 | CFBundleTypeName 260 | JPEG image 261 | CFBundleTypeOSTypes 262 | 263 | JPEG 264 | 265 | CFBundleTypeRole 266 | Viewer 267 | ICExtension 268 | JPEG 269 | LSHandlerRank 270 | Default 271 | NSDocumentClass 272 | BrowserDocument 273 | 274 | 275 | CFBundleTypeExtensions 276 | 277 | jp2 278 | 279 | CFBundleTypeIconFile 280 | jp2.icns 281 | CFBundleTypeMIMETypes 282 | 283 | image/jp2 284 | 285 | CFBundleTypeName 286 | JPEG 2000 image 287 | CFBundleTypeOSTypes 288 | 289 | jp2 290 | 291 | CFBundleTypeRole 292 | Viewer 293 | LSHandlerRank 294 | Default 295 | NSDocumentClass 296 | BrowserDocument 297 | 298 | 299 | CFBundleTypeExtensions 300 | 301 | txt 302 | text 303 | 304 | CFBundleTypeIconFile 305 | txt.icns 306 | CFBundleTypeMIMETypes 307 | 308 | text/plain 309 | 310 | CFBundleTypeName 311 | Plain text document 312 | CFBundleTypeOSTypes 313 | 314 | TEXT 315 | 316 | CFBundleTypeRole 317 | Viewer 318 | ICExtension 319 | TXT 320 | LSHandlerRank 321 | Default 322 | NSDocumentClass 323 | BrowserDocument 324 | 325 | 326 | CFBundleTypeExtensions 327 | 328 | png 329 | 330 | CFBundleTypeIconFile 331 | png.icns 332 | CFBundleTypeMIMETypes 333 | 334 | image/png 335 | 336 | CFBundleTypeName 337 | PNG image 338 | CFBundleTypeOSTypes 339 | 340 | PNGf 341 | 342 | CFBundleTypeRole 343 | Viewer 344 | LSHandlerRank 345 | Default 346 | NSDocumentClass 347 | BrowserDocument 348 | 349 | 350 | CFBundleTypeExtensions 351 | 352 | tiff 353 | tif 354 | 355 | CFBundleTypeIconFile 356 | tiff.icns 357 | CFBundleTypeMIMETypes 358 | 359 | image/tiff 360 | 361 | CFBundleTypeName 362 | TIFF image 363 | CFBundleTypeOSTypes 364 | 365 | TIFF 366 | 367 | CFBundleTypeRole 368 | Viewer 369 | ICExtension 370 | TIFF 371 | LSHandlerRank 372 | Default 373 | NSDocumentClass 374 | BrowserDocument 375 | 376 | 377 | CFBundleTypeExtensions 378 | 379 | url 380 | 381 | CFBundleTypeIconFile 382 | url.icns 383 | CFBundleTypeName 384 | Web site location 385 | CFBundleTypeOSTypes 386 | 387 | LINK 388 | 389 | CFBundleTypeRole 390 | Viewer 391 | LSHandlerRank 392 | Default 393 | LSIsAppleDefaultForType 394 | 395 | NSDocumentClass 396 | BrowserDocument 397 | 398 | 399 | CFBundleTypeExtensions 400 | 401 | ico 402 | 403 | CFBundleTypeIconFile 404 | ico.icns 405 | CFBundleTypeMIMETypes 406 | 407 | image/x-icon 408 | 409 | CFBundleTypeName 410 | Windows icon image 411 | CFBundleTypeOSTypes 412 | 413 | ICO 414 | 415 | CFBundleTypeRole 416 | Viewer 417 | LSHandlerRank 418 | Default 419 | NSDocumentClass 420 | BrowserDocument 421 | 422 | 423 | CFBundleTypeExtensions 424 | 425 | xhtml 426 | xht 427 | xhtm 428 | xht 429 | 430 | CFBundleTypeIconFile 431 | xhtml.icns 432 | CFBundleTypeMIMETypes 433 | 434 | application/xhtml+xml 435 | 436 | CFBundleTypeName 437 | XHTML document 438 | CFBundleTypeRole 439 | Viewer 440 | ICExtension 441 | XHTML 442 | LSHandlerRank 443 | Default 444 | NSDocumentClass 445 | BrowserDocument 446 | 447 | 448 | CFBundleTypeExtensions 449 | 450 | xml 451 | xbl 452 | xsl 453 | xslt 454 | 455 | CFBundleTypeIconFile 456 | xml.icns 457 | CFBundleTypeMIMETypes 458 | 459 | application/xml 460 | text/xml 461 | 462 | CFBundleTypeName 463 | XML document 464 | CFBundleTypeRole 465 | Viewer 466 | ICExtension 467 | XML 468 | LSHandlerRank 469 | Default 470 | NSDocumentClass 471 | BrowserDocument 472 | 473 | 474 | CFBundleTypeExtensions 475 | 476 | svg 477 | 478 | CFBundleTypeIconFile 479 | svg.icns 480 | CFBundleTypeMIMETypes 481 | 482 | image/svg+xml 483 | 484 | CFBundleTypeName 485 | SVG document 486 | CFBundleTypeRole 487 | Viewer 488 | LSHandlerRank 489 | Default 490 | NSDocumentClass 491 | BrowserDocument 492 | 493 | 494 | CFBundleExecutable 495 | Safari 496 | CFBundleGetInfoString 497 | 10.0.2, Copyright © 2003-2016 Apple Inc. 498 | CFBundleHelpBookFolder 499 | Safari.help 500 | CFBundleHelpBookName 501 | com.apple.Safari.help 502 | CFBundleIconFile 503 | compass 504 | CFBundleIdentifier 505 | com.apple.Safari 506 | CFBundleInfoDictionaryVersion 507 | 6.0 508 | CFBundleName 509 | Safari 510 | CFBundlePackageType 511 | APPL 512 | CFBundleShortVersionString 513 | 10.0.2 514 | CFBundleSignature 515 | sfri 516 | CFBundleSupportedPlatforms 517 | 518 | MacOSX 519 | 520 | CFBundleURLTypes 521 | 522 | 523 | CFBundleURLName 524 | Web site URL 525 | CFBundleURLSchemes 526 | 527 | http 528 | https 529 | 530 | LSHandlerRank 531 | Default 532 | LSIsAppleDefaultForScheme 533 | 534 | 535 | 536 | CFBundleURLName 537 | Local file URL 538 | CFBundleURLSchemes 539 | 540 | file 541 | 542 | LSHandlerRank 543 | Default 544 | 545 | 546 | CFBundleVersion 547 | 12602.3.12.0.1 548 | DTCompiler 549 | com.apple.compilers.llvm.clang.1_0 550 | DTPlatformBuild 551 | 8R174l 552 | DTPlatformVersion 553 | GM 554 | DTSDKBuild 555 | 16C56 556 | DTSDKName 557 | macosx10.12internal 558 | DTXcode 559 | 0800 560 | DTXcodeBuild 561 | 8R174l 562 | LSApplicationCategoryType 563 | public.app-category.productivity 564 | LSFileQuarantineEnabled 565 | 566 | LSMinimumSystemVersion 567 | 10.12.0 568 | NSAppleScriptEnabled 569 | Yes 570 | NSLocationUsageDescription 571 | Websites you visit may request your location. 572 | NSMainNibFile 573 | MainMenu 574 | NSPrincipalClass 575 | BrowserApplication 576 | NSServices 577 | 578 | 579 | NSKeyEquivalent 580 | 581 | default 582 | L 583 | 584 | NSMenuItem 585 | 586 | default 587 | Search With %WebSearchProvider@ 588 | 589 | NSMessage 590 | searchWithWebSearchProvider 591 | NSPortName 592 | Safari 593 | NSSendTypes 594 | 595 | public.utf8-plain-text 596 | 597 | 598 | 599 | NSMenuItem 600 | 601 | default 602 | Add to Reading List 603 | 604 | NSMessage 605 | addToReadingList 606 | NSPortName 607 | Safari 608 | NSRequiredContext 609 | 610 | 611 | NSTextContent 612 | URL 613 | 614 | 615 | NSLinkSchemes 616 | 617 | http 618 | https 619 | 620 | 621 | 622 | NSSendTypes 623 | 624 | public.rtf 625 | public.utf8-plain-text 626 | 627 | 628 | 629 | NSSupportsAutomaticTermination 630 | 631 | NSSupportsSuddenTermination 632 | 633 | NSUserActivityTypes 634 | 635 | NSUserActivityTypeBrowsingWeb 636 | 637 | UTExportedTypeDeclarations 638 | 639 | 640 | UTTypeConformsTo 641 | 642 | public.data 643 | 644 | UTTypeDescription 645 | Safari bookmark 646 | UTTypeIdentifier 647 | com.apple.safari.bookmark 648 | UTTypeTagSpecification 649 | 650 | public.filename-extension 651 | 652 | webbookmark 653 | 654 | 655 | 656 | 657 | UTTypeConformsTo 658 | 659 | public.data 660 | 661 | UTTypeDescription 662 | Safari extension 663 | UTTypeIdentifier 664 | com.apple.safari.extension 665 | UTTypeTagSpecification 666 | 667 | public.filename-extension 668 | 669 | safariextz 670 | 671 | 672 | 673 | 674 | UTTypeConformsTo 675 | 676 | public.data 677 | 678 | UTTypeDescription 679 | Safari history item 680 | UTTypeIdentifier 681 | com.apple.safari.history 682 | UTTypeTagSpecification 683 | 684 | public.filename-extension 685 | 686 | webhistory 687 | 688 | 689 | 690 | 691 | 692 | 693 | -------------------------------------------------------------------------------- /tests/test_data/Applications/Safari.app/Contents/Resources/compass.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andriyko/browser-dispatcher/1356b1a60c31e7e4993ac461fb3f0c1db951e6b5/tests/test_data/Applications/Safari.app/Contents/Resources/compass.icns -------------------------------------------------------------------------------- /tests/unit/evaluators.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chai = require('chai'); 4 | const expect = chai.expect; // we are using the "expect" style of Chai 5 | const evaluators = require('../.././src/app/js/evaluators'); 6 | 7 | let TEST_STRING = 'test'; 8 | 9 | let RULE_SIMPLE_ANY_CONDITION = { 10 | _id: 'c0GunX7bGVp6azrw', 11 | name: 'Integrations', 12 | is_active: true, 13 | operator: 'any', 14 | browser: { 15 | _id: 'Cat1TxlqAgair0BQ', 16 | name: 'Safari', 17 | path: '/Applications/Safari.app', 18 | icns: '/Applications/Safari.app/Contents/Resources/compass.icns', 19 | display_name: 'Safari', 20 | executable: 'Safari', 21 | identifier: 'com.apple.Safari', 22 | is_default: true, 23 | is_active: true 24 | }, 25 | open_new_instance: false, 26 | open_not_foreground: false, 27 | open_fresh: false, 28 | use_app_executable: false, 29 | open_args: '', 30 | conditions: [ 31 | { 32 | text: 'salesforce.com', 33 | operand: 'host', 34 | operator: 'contains' 35 | }, 36 | { 37 | text: 'developerforce.com', 38 | operand: 'host', 39 | operator: 'contains' 40 | }, 41 | { 42 | text: 'service-now.com', 43 | operand: 'host', 44 | operator: 'contains' 45 | }, 46 | { 47 | text: 'my.okta.com', 48 | operand: 'host', 49 | operator: 'is' 50 | }, 51 | { 52 | text: 'console.aws.amazon.com', 53 | operand: 'host', 54 | operator: 'is' 55 | } 56 | ] 57 | }; 58 | 59 | let RULE_SIMPLE_ALL_CONDITIONS = { 60 | _id: 'cxrzMcPAACGvGXtH', 61 | name: 'andriyko Github', 62 | is_active: true, 63 | operator: 'all', 64 | browser: { 65 | _id: 'REIbACm6FRbU1SHH', 66 | name: 'Opera', 67 | path: '/Applications/Opera.app', 68 | icns: '/Applications/Opera.app/Contents/Resources/app.icns', 69 | display_name: 'Opera', 70 | executable: 'Opera', 71 | identifier: 'com.operasoftware.Opera', 72 | is_default: false, 73 | is_active: true 74 | }, 75 | open_new_instance: false, 76 | open_not_foreground: false, 77 | open_fresh: false, 78 | use_app_executable: false, 79 | open_args: '', 80 | conditions: [ 81 | { 82 | text: 'github.com', 83 | operand: 'host', 84 | operator: 'is' }, 85 | { 86 | text: '/andriyko', 87 | operand: 'path', 88 | operator: 'starts_with' 89 | } 90 | ] 91 | }; 92 | 93 | let RULE_ADVANCED_OPTIONS = { 94 | _id: 'S4H1HASJ8FB41WwQ', 95 | name: 'Social', 96 | is_active: true, 97 | operator: 'any', 98 | browser: { 99 | _id: 'AcWgWPZbo7SPP5lY', 100 | name: 'Firefox', 101 | path: '/Applications/Firefox.app', 102 | icns: '/Applications/Firefox.app/Contents/Resources/firefox.icns', 103 | display_name: 'Firefox', 104 | executable: 'firefox', 105 | identifier: 'org.mozilla.firefox', 106 | is_default: false, 107 | is_active: true 108 | }, 109 | open_new_instance: true, 110 | open_not_foreground: false, 111 | open_fresh: false, 112 | use_app_executable: false, 113 | open_args: '-P “SocialNetworking”', 114 | conditions: [ 115 | { 116 | text: 'facebook.com', 117 | operand: 'host', 118 | operator: 'is' 119 | }, 120 | { 121 | text: 'instagram.com', 122 | operand: 'host', 123 | operator: 'is' 124 | }, 125 | { 126 | text: 'linkedin.com', 127 | operand: 'host', 128 | operator: 'is' 129 | }, 130 | { 131 | text: 'reddit.com', 132 | operand: 'host', 133 | operator: 'is' 134 | } 135 | ] 136 | }; 137 | 138 | let RULE_INACTIVE = { 139 | _id: 'oeJGcxq5zvGAuskW', 140 | name: 'Local Dev', 141 | is_active: false, 142 | operator: 'any', 143 | browser: { 144 | _id: 'XlJxf2AIAGFdtZ2J', 145 | name: 'Chrome', 146 | path: '/Applications/Google Chrome.app', 147 | icns: '/Applications/Google Chrome.app/Contents/Resources/app.icns', 148 | display_name: 'Google Chrome', 149 | executable: 'Google Chrome', 150 | identifier: 'com.google.Chrome', 151 | is_default: false, 152 | is_active: true 153 | }, 154 | open_new_instance: false, 155 | open_not_foreground: false, 156 | open_fresh: false, 157 | use_app_executable: false, 158 | open_args: '', 159 | conditions: [ 160 | { 161 | text: '15672', 162 | operand: 'port', 163 | operator: 'is' 164 | }, 165 | { 166 | text: '5555', 167 | operand: 'port', 168 | operator: 'is' 169 | }, 170 | { 171 | text: '5601', 172 | operand: 'port', 173 | operator: 'is' 174 | }, 175 | { 176 | text: '6555', 177 | operand: 'port', 178 | operator: 'is' 179 | }, 180 | { 181 | text: '6543', 182 | operand: 'port', 183 | operator: 'is' 184 | }, 185 | { 186 | text: '6545', 187 | operand: 'port', 188 | operator: 'is' 189 | } 190 | ] 191 | }; 192 | 193 | let RULES = [ 194 | RULE_SIMPLE_ANY_CONDITION, 195 | RULE_SIMPLE_ALL_CONDITIONS, 196 | RULE_ADVANCED_OPTIONS, 197 | RULE_INACTIVE 198 | ]; 199 | 200 | describe('Operands', function () { 201 | describe('OperandApp', function () { 202 | it('supportedOperators() of OperandApp returns correct list of operators', function () { 203 | expect(evaluators._OperandApp.uid).to.be.equal('app'); 204 | expect(evaluators._OperandApp.supportedOperators).to.deep.equal(['is', 'is_not']); 205 | }); 206 | }); 207 | 208 | describe('OperandHost', function () { 209 | it('supportedOperators() of OperandHost returns correct list of operators', function () { 210 | expect(evaluators._OperandHost.uid).to.be.equal('host'); 211 | expect(evaluators._OperandHost.supportedOperators).to.deep.equal([ 212 | 'is', 213 | 'is_not', 214 | 'contains', 215 | 'not_contains', 216 | 'starts_with', 217 | 'not_starts_with', 218 | 'ends_with', 219 | 'not_ends_with']); 220 | }); 221 | }); 222 | 223 | describe('OperandScheme', function () { 224 | it('supportedOperators() of OperandScheme returns correct list of operators', function () { 225 | expect(evaluators._OperandScheme.uid).to.be.equal('scheme'); 226 | expect(evaluators._OperandScheme.supportedOperators).to.deep.equal(['is', 'is_not']); 227 | }); 228 | }); 229 | 230 | describe('OperandPath', function () { 231 | it('supportedOperators() of OperandPath returns correct list of operators', function () { 232 | expect(evaluators._OperandPath.uid).to.be.equal('path'); 233 | expect(evaluators._OperandPath.supportedOperators).to.deep.equal([ 234 | 'is', 235 | 'is_not', 236 | 'contains', 237 | 'not_contains', 238 | 'starts_with', 239 | 'not_starts_with', 240 | 'ends_with', 241 | 'not_ends_with']); 242 | }); 243 | }); 244 | 245 | describe('OperandPort', function () { 246 | it('supportedOperators() of OperandPort returns correct list of operators', function () { 247 | expect(evaluators._OperandPort.uid).to.be.equal('port'); 248 | expect(evaluators._OperandPort.supportedOperators).to.deep.equal(['is', 'is_not']); 249 | }); 250 | }); 251 | 252 | describe('OperandExtension', function () { 253 | it('supportedOperators() of OperandExtension returns correct list of operators', function () { 254 | expect(evaluators._OperandExtension.uid).to.be.equal('extension'); 255 | expect(evaluators._OperandExtension.supportedOperators).to.deep.equal(['is', 'is_not']); 256 | }); 257 | }); 258 | 259 | describe('OperandUrl', function () { 260 | it('supportedOperators() of OperandUrl returns correct list of operators', function () { 261 | expect(evaluators._OperandUrL.uid).to.be.equal('url'); 262 | expect(evaluators._OperandUrL.supportedOperators).to.deep.equal(['regular_expression']); 263 | }); 264 | }); 265 | 266 | describe('getConditionOperands', function () { 267 | it('getConditionOperators() should return mapping of operands classes', function () { 268 | let ops = evaluators.getConditionOperands(); 269 | let keys = ['host', 'scheme', 'path', 'port', 'url']; 270 | expect(ops).to.be.an('object'); 271 | expect(ops).to.have.all.keys(keys); 272 | }); 273 | }); 274 | }); 275 | 276 | describe('Operators', function () { 277 | describe('OperatorIs', function () { 278 | it('evaluate() should check values equality', function () { 279 | let operatorIs = new evaluators._OperatorIs(TEST_STRING); 280 | expect(operatorIs.evaluate('not test')).to.be.false; 281 | expect(operatorIs.evaluate('test')).to.be.true; 282 | expect(operatorIs.evaluate(' test ')).to.be.true; 283 | expect(operatorIs.evaluate('TeSt')).to.be.true; 284 | }); 285 | }); 286 | 287 | describe('OperatorIsNot', function () { 288 | it('evaluate() should check values inequality', function () { 289 | let operatorIsNot = new evaluators._OperatorIsNot(TEST_STRING); 290 | expect(operatorIsNot.evaluate('not test')).to.be.true; 291 | expect(operatorIsNot.evaluate('test')).to.be.false; 292 | expect(operatorIsNot.evaluate(' test ')).to.be.false; 293 | expect(operatorIsNot.evaluate('TeSt')).to.be.false; 294 | }); 295 | }); 296 | 297 | describe('OperatorContains', function () { 298 | it('evaluate() should check value contains string', function () { 299 | let operatorContains = new evaluators._OperatorContains(TEST_STRING); 300 | expect(operatorContains.evaluate('not test')).to.be.true; 301 | expect(operatorContains.evaluate(' test ')).to.be.true; 302 | expect(operatorContains.evaluate('TeSt')).to.be.true; 303 | expect(operatorContains.evaluate('nottest')).to.be.true; 304 | expect(operatorContains.evaluate('tteest')).to.be.false; 305 | }); 306 | }); 307 | 308 | describe('OperatorNotContains', function () { 309 | it('evaluate() should check value does not contain string', function () { 310 | let operatorNotContains = new evaluators._OperatorNotContains(TEST_STRING); 311 | expect(operatorNotContains.evaluate('not test')).to.be.false; 312 | expect(operatorNotContains.evaluate(' test ')).to.be.false; 313 | expect(operatorNotContains.evaluate('TeSt')).to.be.false; 314 | expect(operatorNotContains.evaluate('nottest')).to.be.false; 315 | expect(operatorNotContains.evaluate('tteest')).to.be.true; 316 | }); 317 | }); 318 | 319 | describe('OperatorStartsWith', function () { 320 | it('evaluate() should check value starts with string', function () { 321 | let operatorStartsWith = new evaluators._OperatorStartsWith(TEST_STRING); 322 | expect(operatorStartsWith.evaluate('not test')).to.be.false; 323 | expect(operatorStartsWith.evaluate('this is test')).to.be.false; 324 | expect(operatorStartsWith.evaluate('test this')).to.be.true; 325 | expect(operatorStartsWith.evaluate('TeSt this')).to.be.true; 326 | expect(operatorStartsWith.evaluate('testing')).to.be.true; 327 | expect(operatorStartsWith.evaluate(' testing')).to.be.true; 328 | }); 329 | }); 330 | 331 | describe('OperatorNotStartsWith', function () { 332 | it('evaluate() should check value does not start with string', function () { 333 | let operatorNotStartsWith = new evaluators._OperatorNotStartsWith(TEST_STRING); 334 | expect(operatorNotStartsWith.evaluate('not test')).to.be.true; 335 | expect(operatorNotStartsWith.evaluate('this is test')).to.be.true; 336 | expect(operatorNotStartsWith.evaluate('test this')).to.be.false; 337 | expect(operatorNotStartsWith.evaluate('TeSt this')).to.be.false; 338 | expect(operatorNotStartsWith.evaluate('testing')).to.be.false; 339 | expect(operatorNotStartsWith.evaluate(' testing')).to.be.false; 340 | }); 341 | }); 342 | 343 | describe('OperatorEndsWith', function () { 344 | it('evaluate() should check value ends with string', function () { 345 | let operatorEndsWith = new evaluators._OperatorEndsWith(TEST_STRING); 346 | expect(operatorEndsWith.evaluate('this is test')).to.be.true; 347 | expect(operatorEndsWith.evaluate('another test ')).to.be.true; 348 | expect(operatorEndsWith.evaluate('test done')).to.be.false; 349 | expect(operatorEndsWith.evaluate('tests')).to.be.false; 350 | expect(operatorEndsWith.evaluate('testing')).to.be.false; 351 | expect(operatorEndsWith.evaluate('tester')).to.be.false; 352 | }); 353 | }); 354 | 355 | describe('OperatorNotEndsWith', function () { 356 | it('evaluate() should check value ends with string', function () { 357 | let operatorNotEndsWith = new evaluators._OperatorNotEndsWith(TEST_STRING); 358 | expect(operatorNotEndsWith.evaluate('this is test')).to.be.false; 359 | expect(operatorNotEndsWith.evaluate('another test ')).to.be.false; 360 | expect(operatorNotEndsWith.evaluate('test done')).to.be.true; 361 | expect(operatorNotEndsWith.evaluate('tests')).to.be.true; 362 | expect(operatorNotEndsWith.evaluate('testing')).to.be.true; 363 | expect(operatorNotEndsWith.evaluate('tester')).to.be.true; 364 | }); 365 | }); 366 | 367 | describe('OperatorRegexp', function () { 368 | it('evaluate() should check value ends with string', function () { 369 | // eslint-disable-next-line no-useless-escape 370 | let operatorRegexp = new evaluators._OperatorRegexp('^(http|https)\:\/\/\\w+\.'); 371 | expect(operatorRegexp.evaluate('http://google.com')).to.be.true; 372 | expect(operatorRegexp.evaluate('https://google.com')).to.be.true; 373 | expect(operatorRegexp.evaluate('ftp://mozzila.com')).to.be.false; 374 | }); 375 | }); 376 | 377 | describe('getConditionOperators', function () { 378 | it('getConditionOperators() should return mapping of operators classes', function () { 379 | let ops = evaluators.getConditionOperators(); 380 | let keys = ['is', 'contains', 'starts_with', 'ends_with', 'is_not', 381 | 'not_contains', 'not_starts_with', 'not_ends_with', 'regular_expression']; 382 | expect(ops).to.be.an('object'); 383 | expect(ops).to.have.all.keys(keys); 384 | }); 385 | }); 386 | }); 387 | 388 | describe('RuleEvaluator', function () { 389 | it('_evaluateCondition() should return boolean', function () { 390 | let conditionToEvaluate = { 391 | text: 'google.com', 392 | operand: 'host', 393 | operator: 'is' 394 | }; 395 | let objectToEvaluate = { 396 | host: 'notgoogle.com', 397 | scheme: 'https:', 398 | path: '/', 399 | port: '443' 400 | }; 401 | let RuleEvaluator = new evaluators._RuleEvaluator({}); 402 | 403 | expect(RuleEvaluator._evaluateCondition(conditionToEvaluate, objectToEvaluate)).to.be.false; 404 | 405 | objectToEvaluate['host'] = conditionToEvaluate['text']; 406 | expect(RuleEvaluator._evaluateCondition(conditionToEvaluate, objectToEvaluate)).to.be.true; 407 | }); 408 | 409 | it('_any() should return true if at least one of the rule conditions match the evaluated object', function () { 410 | let objectToEvaluate = { 411 | host: 'console.aws.amazon.com', 412 | scheme: 'https:', 413 | path: '/', 414 | port: '443' 415 | }; 416 | let RuleEvaluator = new evaluators._RuleEvaluator(RULE_SIMPLE_ANY_CONDITION); 417 | expect(RuleEvaluator._any(objectToEvaluate)).to.be.true; 418 | 419 | objectToEvaluate['host'] = 'facebook.com'; 420 | expect(RuleEvaluator._any(objectToEvaluate)).to.be.false; 421 | }); 422 | 423 | it('_all() should return true if all of the rule conditions match the evaluated object', function () { 424 | let objectToEvaluate = { 425 | host: 'github.com', 426 | scheme: 'https:', 427 | path: '/andriyko', 428 | port: '443' 429 | }; 430 | let RuleEvaluator = new evaluators._RuleEvaluator(RULE_SIMPLE_ALL_CONDITIONS); 431 | expect(RuleEvaluator._all(objectToEvaluate)).to.be.true; 432 | 433 | objectToEvaluate['path'] = '/notandriyko'; 434 | expect(RuleEvaluator._all(objectToEvaluate)).to.be.false; 435 | }); 436 | 437 | it('evaluate() should use _all() or _any() based on the rule operator', function () { 438 | let objectToEvaluate = { 439 | host: 'github.com', 440 | scheme: 'https:', 441 | path: '/andriyko', 442 | port: '443' 443 | }; 444 | let RuleEvaluator = new evaluators._RuleEvaluator(RULE_SIMPLE_ALL_CONDITIONS); 445 | expect(RuleEvaluator.evaluate(objectToEvaluate)).to.be.true; 446 | }); 447 | }); 448 | 449 | describe('crateObjectToEvaluate', function () { 450 | it('crateObjectToEvaluate() should parse URL and transform it to object', function () { 451 | let expectedResult = { 452 | host: 'google.com', 453 | scheme: 'https:', 454 | path: '/', 455 | port: '443' 456 | }; 457 | let result = evaluators.crateObjectToEvaluate('https://google.com:443'); 458 | for (let k of Object.keys(expectedResult)) { 459 | expect(result[k]).to.be.equal(expectedResult[k]); 460 | } 461 | }); 462 | }); 463 | 464 | describe('evaluateRule', function () { 465 | it('evaluateRule() should return boolean based on the rule evaluation', function () { 466 | let objectToEvaluate = { 467 | host: 'github.com', 468 | scheme: 'https:', 469 | path: '/andriyko', 470 | port: '443' 471 | }; 472 | let result = evaluators.evaluateRule(RULE_SIMPLE_ALL_CONDITIONS, objectToEvaluate); 473 | expect(result).to.be.true; 474 | 475 | objectToEvaluate['path'] = 'notandriyko'; 476 | result = evaluators.evaluateRule(RULE_SIMPLE_ALL_CONDITIONS, objectToEvaluate); 477 | expect(result).to.be.false; 478 | }); 479 | }); 480 | 481 | describe('evaluateRules', function () { 482 | it('evaluateRules() should evaluate object against all the rules', function () { 483 | let rule = evaluators.evaluateRules(RULES, 'https://github.com/andriyko'); 484 | expect(rule._id).to.be.equal(RULE_SIMPLE_ALL_CONDITIONS._id); 485 | 486 | rule = evaluators.evaluateRules(RULES, 'https://github.com/notandriyko'); 487 | expect(rule).to.be.equal(null); 488 | }); 489 | }); 490 | -------------------------------------------------------------------------------- /tests/unit/open2.test.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const expect = chai.expect; // we are using the "expect" style of Chai 3 | const proxyquire = require('proxyquire'); 4 | const open2 = proxyquire('../.././src/app/js/open2', { 5 | 'child_process': { 6 | exec: function (cmd) { return cmd; } 7 | } 8 | }); 9 | 10 | describe('open2', function () { 11 | it('open2() opens URL in a browser using its bundle identifier', function () { 12 | let result = open2('http://google.com', 'com.google.Chrome', '-b'); 13 | expect(result).to.be.equal('open -b "com.google.Chrome" "http://google.com"'); 14 | }); 15 | 16 | it('open2() opens URL in a browser using its app identifier', function () { 17 | let result = open2('http://google.com', 'firefox', '-a'); 18 | expect(result).to.be.equal('open -a "firefox" "http://google.com"'); 19 | }); 20 | 21 | it('open2() opens URL in a browser with a set of options', function () { 22 | let result = open2('http://google.com', 'com.google.Chrome', '-b', '-n -F'); 23 | expect(result).to.be.equal('open -b "com.google.Chrome" -n -F "http://google.com"'); 24 | }); 25 | 26 | it('open2() opens URL in a browser with a set of additional arguments', function () { 27 | let result = open2('http://google.com', 'firefox', '-a', null, '-P "testProfile"'); 28 | expect(result).to.be.equal('open -a "firefox" "http://google.com" −−args -P "testProfile"'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/unit/utils.test.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const expect = chai.expect; // we are using the "expect" style of Chai 3 | const utils = require('../.././src/app/js/utils'); 4 | const path = require('path'); 5 | 6 | describe('Get apps details', function () { 7 | it('getAppInfo() returns correct app details', function () { 8 | let appPath = path.resolve(path.join(__dirname, '..', 'test_data', 'Applications', 'Safari.app')); 9 | let expectedAppInfo = { 10 | 'display_name': 'Safari', 11 | 'executable': 'Safari', 12 | 'icns': path.join(appPath, 'Contents/Resources/compass.icns'), 13 | 'identifier': 'com.apple.Safari', 14 | 'is_browser': true, 15 | 'name': 'Safari', 16 | 'path': appPath, 17 | 'schemes': ['http'] 18 | }; 19 | let result = utils.getAppInfo(appPath); 20 | expect(result).to.deep.equal(expectedAppInfo); 21 | }); 22 | 23 | it('getApps() returns correct list of apps and ignores Calculator.app', function () { 24 | let appsRoot = path.resolve(path.join(__dirname, '..', 'test_data', 'Applications')); 25 | let expectedResult = [ 26 | { 27 | 'display_name': 'Google Chrome', 28 | 'executable': 'Google Chrome', 29 | 'icns': path.join(appsRoot, 'Google Chrome.app/Contents/Resources/app.icns'), 30 | 'identifier': 'com.google.Chrome', 31 | 'is_browser': true, 32 | 'name': 'Chrome', 33 | 'path': path.join(appsRoot, 'Google Chrome.app'), 34 | 'schemes': ['http'] 35 | }, 36 | { 37 | 'display_name': 'Safari', 38 | 'executable': 'Safari', 39 | 'icns': path.join(appsRoot, 'Safari.app/Contents/Resources/compass.icns'), 40 | 'identifier': 'com.apple.Safari', 41 | 'is_browser': true, 42 | 'name': 'Safari', 43 | 'path': path.join(appsRoot, 'Safari.app'), 44 | 'schemes': ['http'] 45 | } 46 | ]; 47 | let result = utils.getApps(appsRoot); 48 | expect(result).to.deep.equal({errors: [], results: expectedResult}); 49 | }); 50 | }); 51 | --------------------------------------------------------------------------------